Detailed changes
@@ -0,0 +1,59 @@
+# Database files
+shelley.db
+shelley.db*
+test-playwright.db
+test-playwright.db*
+test-e2e.db
+test-e2e.db*
+test*.db
+*.db-shm
+*.db-wal
+
+# Build outputs
+/shelley
+bin/
+templates/*.tar.gz
+
+# UI build outputs
+ui/dist/
+ui/node_modules/
+ui/.next/
+ui/out/
+
+# Playwright test outputs
+ui/test-results/
+ui/playwright-report/
+
+# Screenshots (keep only example ones)
+ui/e2e/screenshots/*
+!ui/e2e/screenshots/.gitkeep
+
+# Logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Coverage directory used by tools like istanbul
+coverage/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
@@ -0,0 +1,28 @@
+1. Never add sleeps to tests.
+2. Brevity, brevity, brevity! Do not do weird defaults; have only one way of doing things; refactor relentlessly as necessary.
+3. If something doesn't work, propagate the error or exit or crash. Do not have "fallbacks".
+4. Do not keep old methods around for "compatibility"; this is a new project and there
+ are no compatibility concerns yet.
+5. The "predictable" model is a test fixture that lets you specify what a model would say if you said
+ a thing. This is useful for interactive testing with a browser, since you don't rely on a model,
+ and can fabricate some inputs and outputs. To test things, launch shelley with the relevant flag
+ to only expose this model, and use shelley with a browser.
+6. Build the UI (`make ui` or `cd ui && npm install && npm run build`) before running Go tests so `ui/dist` exists for the embed.
+7. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
+8. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
+ ```javascript
+ const input = document.querySelector('[data-testid="message-input"]');
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
+ nativeInputValueSetter.call(input, 'your message');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ ```
+ Simply setting `input.value = '...'` won't work because React won't detect the change.
+9. Commit your changes before finishing your turn.
+10. If you are testing Shelley itself, be aware that you might be running "under" shelley,
+ and indiscrimenantly running pkill -f shelley may break things.
+11. To test the Shelley UI in a separate instance, build with `make build`, then run on a
+ different port with a separate database:
+ ```
+ ./bin/shelley -config /exe.dev/shelley.json -db /tmp/shelley-test.db serve -port 8002
+ ```
+ Then use browser tools to navigate to http://localhost:8002/ and interact with the UI.
@@ -0,0 +1,245 @@
+# Shelley Agent Testing Guide
+
+This document provides instructions for automated testing of the Shelley coding agent product.
+
+## Prerequisites
+
+- `ANTHROPIC_API_KEY` environment variable set
+- Node.js and npm installed
+- Go installed
+- `headless` browser tool available (check with `which headless`)
+
+## Setup Instructions
+
+### 1. Build Shelley
+
+```bash
+cd /path/to/shelley
+make build
+```
+
+This will:
+- Build the UI (`npm install && npm run build`)
+- Create template tarballs
+- Build the Go binary to `bin/shelley`
+
+### 2. Install Playwright for E2E Tests
+
+```bash
+cd ui
+npm install
+npx playwright install chromium
+```
+
+### 3. Start Shelley Server
+
+For testing with Claude:
+```bash
+./bin/shelley --model claude-sonnet-4.5 --db test.db serve --port 9001
+```
+
+For testing with predictable model (no API key needed):
+```bash
+./bin/shelley --model predictable --db test.db serve --port 9001
+```
+
+### 4. Start Headless Browser (if using headless tool)
+
+```bash
+headless start
+```
+
+## Test Categories
+
+### CLI Tests
+
+Test these commands manually:
+
+```bash
+# List available models
+./bin/shelley models
+```
+
+### E2E Tests (Automated)
+
+Run the full E2E test suite:
+
+```bash
+cd ui
+npm run test:e2e
+```
+
+Run specific test files:
+```bash
+npm run test:e2e -- --grep "smoke"
+npm run test:e2e -- --grep "conversation"
+npm run test:e2e -- --grep "cancellation"
+```
+
+### Headless Browser Testing
+
+```bash
+# Navigate to Shelley
+headless navigate http://localhost:9001
+
+# Check page title
+headless eval 'document.title'
+
+# Get page content
+headless eval 'document.body.innerText.slice(0, 2000)'
+
+# Take screenshot
+headless screenshot screenshot.png
+
+# Set input value (React-compatible method)
+headless eval '(() => {
+ const input = document.querySelector("[data-testid=\"message-input\"]");
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set;
+ setter.call(input, "Your message here");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ return "done";
+})()'
+
+# Click send button
+headless eval 'document.querySelector("[data-testid=\"send-button\"]").click()'
+
+# Check if agent is thinking
+headless eval 'document.querySelector("[data-testid=\"agent-thinking\"]")?.innerText || "not thinking"'
+
+# Check for errors
+headless eval 'document.querySelector("[role=\"alert\"]")?.innerText || "no errors"'
+```
+
+## Test Checklist
+
+### Things That Work Well (Regression Tests)
+
+- [ ] **Page loads correctly** - Title is "Shelley", message input visible
+- [ ] **Send button state** - Disabled when empty, enabled when text entered
+- [ ] **Claude integration** - Messages send and receive responses (~2-3 seconds)
+- [ ] **Prompt caching** - Check server logs for `cache_read_input_tokens`
+- [ ] **Tool execution - bash** - Ask to run `echo hello`, verify tool output
+- [ ] **Tool execution - think** - Send `think: analyzing...`, verify think tool appears
+- [ ] **Tool execution - patch** - Send `patch: test.txt`, verify patch tool appears
+- [ ] **Conversation persistence** - Multiple messages in same conversation work
+- [ ] **Enter key sends** - Press Enter in textarea to send message
+- [ ] **Model selector** - Shows available models in UI
+- [ ] **Working directory** - Shows current directory path
+- [ ] **Accessibility labels** - Input has `aria-label="Message input"`, button has `aria-label="Send message"`
+
+### Known Issues (Need Fixing/Re-checking)
+
+- [ ] **Empty message bug (CRITICAL)** - Rapid sequential messages cause 400 errors
+ - Test: Send 5+ messages quickly in succession
+ - Expected: All should succeed
+ - Actual: API returns `messages.N: all messages must have non-empty content`
+
+- [ ] **Cancellation state after reload** - Cancelled operations don't show "cancelled" text
+ - Test: Start `bash: sleep 100`, cancel it, reload page
+ - Expected: Should show "cancelled" or "[Operation cancelled]"
+ - Actual: Shows tool with `x` but no cancelled text
+
+- [ ] **Thinking indicator stuck on error** - Indicator doesn't hide when LLM fails
+ - Test: Trigger an LLM error (e.g., via rapid messages)
+ - Expected: Indicator should hide, error should display
+ - Actual: "Agent working..." stays visible indefinitely
+
+- [ ] **Menu button outside viewport** - Hamburger menu not clickable on mobile
+ - Test: On mobile viewport, try clicking menu button
+ - Expected: Menu should open
+ - Actual: Button reported as "outside of the viewport"
+
+- [ ] **Programmatic input filling** - Direct `.value` assignment doesn't enable send button
+ - Test: Use browser automation to set input value
+ - Expected: Send button should enable
+ - Actual: Button stays disabled (need to use native setter method)
+
+## Screenshots to Capture
+
+When testing, capture these screenshots for the report:
+
+1. `initial-load.png` - Fresh page load
+2. `message-typed.png` - Message in input field
+3. `agent-thinking.png` - Thinking indicator visible
+4. `response-received.png` - After Claude responds
+5. `tool-execution.png` - After a tool (bash/think/patch) runs
+6. `error-state.png` - If any errors occur
+7. `menu-open.png` - Sidebar/conversation list open
+
+## Report Template
+
+Create `test-report/SHELLEY_TEST_REPORT.md` with:
+
+1. **Executive Summary** - Overall pass/fail, key issues
+2. **Test Environment** - Platform, models tested, browser
+3. **Test Results Summary** - Table of categories and pass/fail counts
+4. **Issues Found** - Detailed description of each issue with:
+ - File/location
+ - Description
+ - Expected vs Actual
+ - Screenshot
+ - Impact
+5. **What's Working Well** - Positive findings
+6. **Recommendations** - Prioritized fixes (Critical/High/Medium/Low)
+7. **Screenshots Index** - List of captured screenshots
+
+## Common Issues & Solutions
+
+### Build fails with "no matching files found"
+```bash
+# Templates need to be built first
+make templates
+# Then build
+make build
+```
+
+### Playwright not finding chromium
+```bash
+cd ui
+npx playwright install chromium
+```
+
+### Server already running
+```bash
+# Find and kill existing process
+lsof -i :9001 | grep LISTEN | awk '{print $2}' | xargs kill
+```
+
+### Headless browser already running
+```bash
+headless stop
+headless start
+```
+
+## API Endpoints for Manual Testing
+
+```bash
+# List conversations
+curl http://localhost:9001/api/conversations
+
+# Get specific conversation
+curl http://localhost:9001/api/conversation/<id>
+
+# Create new conversation (POST)
+curl -X POST http://localhost:9001/api/conversations/new \
+ -H "Content-Type: application/json" \
+ -d '{"model":"claude-sonnet-4.5","cwd":"/path/to/dir"}'
+
+# Send message (POST)
+curl -X POST http://localhost:9001/api/conversation/<id>/chat \
+ -H "Content-Type: application/json" \
+ -d '{"content":"Hello!"}'
+
+# Stream conversation (SSE)
+curl http://localhost:9001/api/conversation/<id>/stream
+```
+
+## Server Logs to Watch
+
+When testing, monitor server output for:
+
+- `LLM request completed` - Shows model, duration, token usage, cost
+- `cache_creation_input_tokens` / `cache_read_input_tokens` - Prompt caching
+- `Generated slug for conversation` - Conversation naming
+- `400 Bad Request` or other errors - API failures
+- `Agent message` with `end_of_turn=true` - Conversation turns completing
@@ -0,0 +1,80 @@
+Shelley is an agentic loop with tool use. See
+https://sketch.dev/blog/agent-loop for an example of the idea.
+
+When Shelley is started with "go run ./cmd/shelley" it starts a web server and
+opens a sqlite database, and users interact with the ui built in ui/. (The
+server itself is implemented in server/; cmd/shelley is a very thing shim.)
+
+## Components
+
+### ui/
+
+TODO: A mobile-first UI.
+Infrastructure:
+ * pnpm
+ * Typescript
+ * esbuild
+ * ESLint and eslint-typescript
+ * VueJS
+ * Jest
+
+### db/
+
+conversation(conversation_id, slug, user_initiated):
+
+ Represents a single conversation.
+
+message(conversation_id, message_id, type (agent/user/tool), llm_data (json), user_data (json), usage (json))
+
+ Messages are visible in the UI and sent to the LLM as part of the
+ conversation. There may be both user-visible and llm-visible representations
+ of messages.
+
+The database is sqlite. We use sqlc to define queries and schema.
+
+TODOX: Subagent/tool conversations are done with user_initiated=false.
+
+### server/
+
+The server serves the agent HTTP API and maintains active
+conversations. The HTTP API is:
+
+/conversations?limit=5000&offset=0
+/conversations?q=search_term
+
+ Returns conversations, either matching a query, or matching
+ the paging requirements.
+
+/conversation/<id>
+
+ Returns all the messages within a conversation.
+
+/conversation/<id>/stream
+
+ Returns all the messages within a conversation and
+ uses SSE to wait for updates.
+
+/conversation/<id>/chat (POST)
+
+ Injects a user message into the conversation
+
+
+When a conversation is active (because it's had a message sent to it, or there
+are stream subscribers), a Conversation struct is instantiated from the data,
+and the server keeps a map of these. Each of these has a Loop struct to keep
+track of the interaction with the llm.
+
+## loop/
+
+The core agentic loop.
+
+## claudetool/
+
+Various tools for the LLM.
+
+
+## Other
+
+Shelley talks to the LLMs using the llm/ library.
+
+Logging happens with slog and the tint library.
@@ -0,0 +1,110 @@
+# Shelley Makefile
+
+.PHONY: build build-linux-aarch64 build-linux-x86 test test-go test-e2e ui serve clean help templates
+
+# Default target
+all: build
+
+# Build templates into tarballs
+templates:
+ @echo "Building template tarballs..."
+ @for dir in templates/*/; do \
+ name=$$(basename "$$dir"); \
+ echo " Creating $$name.tar.gz..."; \
+ tar -czf "templates/$$name.tar.gz" -C "templates/$$name" --exclude='.DS_Store' .; \
+ done
+
+# Build the UI and Go binary
+build: ui templates
+ @echo "Building Shelley..."
+ go build -o bin/shelley ./cmd/shelley
+
+# Build for Linux (auto-detect architecture)
+build-linux: ui templates
+ @echo "Building Shelley for Linux..."
+ @ARCH=$$(uname -m); \
+ case $$ARCH in \
+ x86_64) GOARCH=amd64 ;; \
+ aarch64|arm64) GOARCH=arm64 ;; \
+ *) echo "Unsupported architecture: $$ARCH" && exit 1 ;; \
+ esac; \
+ GOOS=linux GOARCH=$$GOARCH go build -o bin/shelley-linux ./cmd/shelley
+
+# Build for Linux ARM64
+build-linux-aarch64: ui templates
+ @echo "Building Shelley for Linux ARM64..."
+ GOOS=linux GOARCH=arm64 go build -o bin/shelley-linux-aarch64 ./cmd/shelley
+
+# Build for Linux x86_64
+build-linux-x86: ui templates
+ @echo "Building Shelley for Linux x86_64..."
+ GOOS=linux GOARCH=amd64 go build -o bin/shelley-linux-x86 ./cmd/shelley
+
+# Build UI
+ui:
+ @echo "Building UI..."
+ cd ui && npm ci && npm run build
+
+# Run Go tests
+test-go: ui
+ @echo "Running Go tests..."
+ go test -v ./...
+
+# Run end-to-end tests
+test-e2e: ui
+ @echo "Running E2E tests..."
+ cd ui && npm run test:e2e
+
+# Run E2E tests in headed mode (with visible browser)
+test-e2e-headed: ui
+ @echo "Running E2E tests (headed)..."
+ cd ui && npm run test:e2e:headed
+
+# Run E2E tests in UI mode
+test-e2e-ui: ui
+ @echo "Opening E2E test UI..."
+ cd ui && npm run test:e2e:ui
+
+# Run all tests
+test: test-go test-e2e
+
+# Serve Shelley with predictable model for testing
+serve-test: ui
+ @echo "Starting Shelley with predictable model..."
+ go run ./cmd/shelley --model predictable --db test.db serve
+
+# Serve Shelley normally
+serve: ui
+ @echo "Starting Shelley..."
+ go run ./cmd/shelley serve
+
+# Clean build artifacts
+clean:
+ @echo "Cleaning..."
+ rm -rf bin/
+ rm -rf ui/dist/
+ rm -rf ui/node_modules/
+ rm -rf ui/test-results/
+ rm -rf ui/playwright-report/
+ rm -f *.db
+ rm -f templates/*.tar.gz
+
+# Show help
+help:
+ @echo "Shelley Build Commands:"
+ @echo ""
+ @echo " build Build UI, templates, and Go binary"
+ @echo " build-linux-aarch64 Build for Linux ARM64"
+ @echo " build-linux-x86 Build for Linux x86_64"
+ @echo " ui Build UI only"
+ @echo " templates Build template tarballs"
+ @echo " test Run all tests (Go + E2E)"
+ @echo " test-go Run Go tests only"
+ @echo " test-e2e Run E2E tests (headless)"
+ @echo " test-e2e-headed Run E2E tests (visible browser)"
+ @echo " test-e2e-ui Open E2E test UI"
+ @echo " serve Start Shelley server"
+ @echo " serve-test Start Shelley with predictable model"
+ @echo " clean Clean build artifacts"
+ @echo " help Show this help"
+
@@ -0,0 +1,70 @@
+# Shelley - A Coding Agent for exe.dev
+
+Shelley is a conversational AI coding agent that provides a web interface
+for AI-powered coding assistance.
+
+See also ARCHITECTURE.md for architectural details.
+
+## Dev Tricks
+
+If you want to see how mobile looks, and you're on your home
+network where you've got mDNS working fine, you can
+run
+ socat TCP-LISTEN:9001,fork TCP:localhost:9000
+and then have your phone go to http://hostname.local:9001/
+
+## CLI Usage
+
+Shelley can be used as a command-line tool with the following commands:
+
+### Global Flags
+
+- `--db <path>`: Path to SQLite database file (default: "shelley.db")
+- `--model <model>`: LLM model to use (use `predictable` for testing). Run `shelley models` to see available models.
+- `--debug`: Enable debug logging
+
+### Commands
+
+#### `serve` - Start Web Server
+
+Starts the web server for the browser-based interface.
+
+```bash
+shelley serve --port 9000
+```
+
+Flags:
+- `--port <port>`: Port to listen on (default: 9000)
+
+#### `models` - List Supported Models
+
+Lists all supported models and their required environment variables.
+
+```bash
+shelley models
+```
+
+### Examples
+
+```bash
+# Start the web server
+shelley serve --port 8080
+
+# List supported models
+shelley models
+
+## Models and API Keys
+
+Use `shelley models` to see supported models, whether they are ready, and the environment variables required for each.
+
+Common env vars:
+
+- `ANTHROPIC_API_KEY`: Required for Claude models.
+- `OPENAI_API_KEY`: Required for OpenAI models.
+- `FIREWORKS_API_KEY`: Required for Fireworks models.
+
+Notes:
+
+- Run `shelley models` to see which model is the default and which are available.
+- `predictable` is a built-in test model and requires no API keys.
+```
@@ -0,0 +1,16 @@
+UI for tools is all shitty; need to collapse tool request and tool call (or maybe
+hide everything)
+
+need to hook up end to end?
+
+main_test is wild; just execute the underlying code dude
+? slugs don't show up when they are generated still
+
+things go too wide out of their box all the time
+
+how does the system prompt work? Is it in the DB?
+
+
+system promopt should include agent.md
+
+UI should be embedded
@@ -0,0 +1,583 @@
+package claudetool
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "shelley.exe.dev/claudetool/bashkit"
+ "shelley.exe.dev/llm"
+)
+
+// PermissionCallback is a function type for checking if a command is allowed to run
+type PermissionCallback func(command string) error
+
+// BashTool specifies an llm.Tool for executing shell commands.
+type BashTool struct {
+ // CheckPermission is called before running any command, if set
+ CheckPermission PermissionCallback
+ // EnableJITInstall enables just-in-time tool installation for missing commands
+ EnableJITInstall bool
+ // Timeouts holds the configurable timeout values (uses defaults if nil)
+ Timeouts *Timeouts
+ // WorkingDir is the shared mutable working directory.
+ WorkingDir *MutableWorkingDir
+ // LLMProvider provides access to LLM services for tool validation
+ LLMProvider LLMServiceProvider
+}
+
+const (
+ EnableBashToolJITInstall = true
+ NoBashToolJITInstall = false
+
+ DefaultFastTimeout = 30 * time.Second
+ DefaultSlowTimeout = 15 * time.Minute
+ DefaultBackgroundTimeout = 24 * time.Hour
+)
+
+// Timeouts holds the configurable timeout values for bash commands.
+type Timeouts struct {
+ Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
+ Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
+ Background time.Duration // background commands (e.g., servers, long-running processes)
+}
+
+// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
+func (t *Timeouts) fast() time.Duration {
+ if t == nil {
+ return DefaultFastTimeout
+ }
+ return t.Fast
+}
+
+// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
+func (t *Timeouts) slow() time.Duration {
+ if t == nil {
+ return DefaultSlowTimeout
+ }
+ return t.Slow
+}
+
+// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
+func (t *Timeouts) background() time.Duration {
+ if t == nil {
+ return DefaultBackgroundTimeout
+ }
+ return t.Background
+}
+
+// Tool returns an llm.Tool based on b.
+func (b *BashTool) Tool() *llm.Tool {
+ return &llm.Tool{
+ Name: bashName,
+ Description: fmt.Sprintf(strings.TrimSpace(bashDescription), b.getWorkingDir()),
+ InputSchema: llm.MustSchema(bashInputSchema),
+ Run: b.Run,
+ }
+}
+
+// getWorkingDir returns the current working directory.
+func (b *BashTool) getWorkingDir() string {
+ return b.WorkingDir.Get()
+}
+
+const (
+ bashName = "bash"
+ bashDescription = `
+Executes shell commands via bash -c, returning combined stdout/stderr.
+Bash state changes (working dir, variables, aliases) don't persist between calls.
+
+With background=true, returns immediately, with output redirected to a file.
+Use background for servers/demos that need to stay running.
+
+MUST set slow_ok=true for potentially slow commands: builds, downloads,
+installs, tests, or any other substantive operation.
+
+To change the working directory persistently, use the change_dir tool.
+
+<pwd>%s</pwd>
+`
+ // If you modify this, update the termui template for prettier rendering.
+ bashInputSchema = `
+{
+ "type": "object",
+ "required": ["command"],
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "Shell to execute"
+ },
+ "slow_ok": {
+ "type": "boolean",
+ "description": "Use extended timeout"
+ },
+ "background": {
+ "type": "boolean",
+ "description": "Execute in background"
+ }
+ }
+}
+`
+)
+
+type bashInput struct {
+ Command string `json:"command"`
+ SlowOK bool `json:"slow_ok,omitempty"`
+ Background bool `json:"background,omitempty"`
+}
+
+type BackgroundResult struct {
+ PID int
+ OutFile string
+}
+
+func (r *BackgroundResult) XMLish() string {
+ return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
+ r.PID, r.OutFile, r.PID)
+}
+
+func (i *bashInput) timeout(t *Timeouts) time.Duration {
+ switch {
+ case i.Background:
+ return t.background()
+ case i.SlowOK:
+ return t.slow()
+ default:
+ return t.fast()
+ }
+}
+
+func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var req bashInput
+ if err := json.Unmarshal(m, &req); err != nil {
+ return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
+ }
+
+ // Check that the working directory exists
+ wd := b.getWorkingDir()
+ if _, err := os.Stat(wd); err != nil {
+ if os.IsNotExist(err) {
+ return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
+ }
+ return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
+ }
+
+ // do a quick permissions check (NOT a security barrier)
+ err := bashkit.Check(req.Command)
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Custom permission callback if set
+ if b.CheckPermission != nil {
+ if err := b.CheckPermission(req.Command); err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ }
+
+ // Check for missing tools and try to install them if needed, best effort only
+ if b.EnableJITInstall {
+ err := b.checkAndInstallMissingTools(ctx, req.Command)
+ if err != nil {
+ slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
+ }
+ }
+
+ timeout := req.timeout(b.Timeouts)
+
+ // If Background is set to true, use executeBackgroundBash
+ if req.Background {
+ result, err := b.executeBackgroundBash(ctx, req, timeout)
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish())}
+ }
+
+ // For foreground commands, use executeBash
+ out, execErr := b.executeBash(ctx, req, timeout)
+ if execErr != nil {
+ return llm.ErrorToolOut(execErr)
+ }
+ return llm.ToolOut{LLMContent: llm.TextContent(out)}
+}
+
+const maxBashOutputLength = 131072
+
+func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
+ cmd := exec.CommandContext(ctx, "bash", "-c", command)
+ // Use shared WorkingDir if available, then context, then Pwd fallback
+ cmd.Dir = b.getWorkingDir()
+ cmd.Stdin = nil
+ cmd.Stdout = out
+ cmd.Stderr = out
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
+ cmd.Cancel = func() error {
+ if cmd.Process == nil {
+ // Process hasn't started yet.
+ // Not sure whether this is possible in practice,
+ // but it is possible in theory, and it doesn't hurt to handle it gracefully.
+ return nil
+ }
+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
+ }
+ cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
+ // Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
+ // and any other future SKETCH_ goodies from the environment.
+ // ...except for SKETCH_PROXY_ID, which is intentionally available.
+ env := slices.DeleteFunc(os.Environ(), func(s string) bool {
+ return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
+ })
+ env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts
+ env = append(env, "EDITOR=/bin/false") // interactive editors won't work
+ cmd.Env = env
+ return cmd
+}
+
+func cmdWait(cmd *exec.Cmd) error {
+ err := cmd.Wait()
+ // We used to kill the process group here, but it's not clear that
+ // this is correct in the case of self-daemonizing processes,
+ // and I encountered issues where daemons that I tried to run
+ // as background tasks would mysteriously exit.
+ return err
+}
+
+func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
+ execCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ output := new(bytes.Buffer)
+ cmd := b.makeBashCommand(execCtx, req.Command, output)
+ // TODO: maybe detect simple interactive git rebase commands and auto-background them?
+ // Would need to hint to the agent what is happening.
+ // We might also be able to do this for other simple interactive commands that use EDITOR.
+ cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("command failed: %w", err)
+ }
+
+ err := cmdWait(cmd)
+
+ out := output.String()
+ out = formatForegroundBashOutput(out)
+
+ if execCtx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
+ }
+ if err != nil {
+ return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
+ }
+
+ return out, nil
+}
+
+// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
+func formatForegroundBashOutput(out string) string {
+ if len(out) > maxBashOutputLength {
+ const snipSize = 4096
+ out = fmt.Sprintf("[output truncated in middle: got %v, max is %v]\n%s\n\n[snip]\n\n%s",
+ humanizeBytes(len(out)), humanizeBytes(maxBashOutputLength),
+ out[:snipSize], out[len(out)-snipSize:],
+ )
+ }
+ return out
+}
+
+func humanizeBytes(bytes int) string {
+ switch {
+ case bytes < 4*1024:
+ return fmt.Sprintf("%dB", bytes)
+ case bytes < 1024*1024:
+ kb := int(math.Round(float64(bytes) / 1024.0))
+ return fmt.Sprintf("%dkB", kb)
+ case bytes < 1024*1024*1024:
+ mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
+ return fmt.Sprintf("%dMB", mb)
+ }
+ return "more than 1GB"
+}
+
+// executeBackgroundBash executes a command in the background and returns the pid and output file locations
+func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
+ // Create temp output files
+ tmpDir, err := os.MkdirTemp("", "sketch-bg-")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create temp directory: %w", err)
+ }
+ // We can't really clean up tempDir, because we have no idea
+ // how far into the future the agent might want to read the output.
+
+ outFile := filepath.Join(tmpDir, "output")
+ out, err := os.Create(outFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create output file: %w", err)
+ }
+
+ execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
+ cmd := b.makeBashCommand(execCtx, req.Command, out)
+ cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
+
+ if err := cmd.Start(); err != nil {
+ cancel()
+ out.Close()
+ os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
+ return nil, fmt.Errorf("failed to start background command: %w", err)
+ }
+
+ // Wait for completion in the background, then do cleanup.
+ go func() {
+ err := cmdWait(cmd)
+ // Leave a note to the agent so that it knows that the process has finished.
+ if err != nil {
+ fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
+ } else {
+ fmt.Fprintf(out, "\n\n[background process completed]\n")
+ }
+ out.Close()
+ cancel()
+ }()
+
+ return &BackgroundResult{
+ PID: cmd.Process.Pid,
+ OutFile: outFile,
+ }, nil
+}
+
+// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
+func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
+ commands, err := bashkit.ExtractCommands(command)
+ if err != nil {
+ return err
+ }
+
+ autoInstallMu.Lock()
+ defer autoInstallMu.Unlock()
+
+ var missing []string
+ for _, cmd := range commands {
+ if doNotAttemptToolInstall[cmd] {
+ continue
+ }
+ _, err := exec.LookPath(cmd)
+ if err == nil {
+ doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
+ continue
+ }
+ missing = append(missing, cmd)
+ }
+
+ if len(missing) == 0 {
+ return nil
+ }
+
+ for _, cmd := range missing {
+ err := b.installTool(ctx, cmd)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
+ }
+ doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
+ }
+ return nil
+}
+
+// Command safety check cache to avoid repeated LLM calls
+var (
+ autoInstallMu sync.Mutex
+ doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
+)
+
+// autodetectPackageManager returns the first packageβmanager binary
+// found in PATH, or an empty string if none are present.
+func autodetectPackageManager() string {
+ // TODO: cache this result with a sync.OnceValue
+
+ managers := []string{
+ "apt", "apt-get", // Debian/Ubuntu
+ "brew", "port", // macOS (Homebrew / MacPorts)
+ "apk", // Alpine
+ "yum", "dnf", // RHEL/Fedora
+ "pacman", // Arch
+ "zypper", // openSUSE
+ "xbps-install", // Void
+ "emerge", // Gentoo
+ "nix-env", "guix", // NixOS / Guix
+ "pkg", // FreeBSD
+ "slackpkg", // Slackware
+ }
+
+ for _, m := range managers {
+ if _, err := exec.LookPath(m); err == nil {
+ return m
+ }
+ }
+ return ""
+}
+
+// installTool attempts to install a single missing tool using LLM validation and system package manager.
+func (b *BashTool) installTool(ctx context.Context, cmd string) error {
+ slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
+
+ packageManager := autodetectPackageManager()
+ if packageManager == "" {
+ return fmt.Errorf("no known package manager found in PATH")
+ }
+ // Use LLM to validate and get package name
+ if b.LLMProvider == nil {
+ return fmt.Errorf("no LLM provider available for tool validation")
+ }
+ llmService, err := b.selectBestLLM()
+ if err != nil {
+ return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
+ }
+
+ query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
+
+Command: %s
+
+- YES: Respond ONLY with the package name used to install it
+- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
+
+ req := &llm.Request{
+ Messages: []llm.Message{{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{llm.StringContent(query)},
+ }},
+ System: []llm.SystemContent{{
+ Type: "text",
+ Text: "You are an expert in software developer tools.",
+ }},
+ }
+
+ resp, err := llmService.Do(ctx, req)
+ if err != nil {
+ return fmt.Errorf("failed to validate tool with LLM: %w", err)
+ }
+
+ if len(resp.Content) == 0 {
+ return fmt.Errorf("empty response from LLM for tool validation")
+ }
+
+ response := strings.TrimSpace(resp.Content[0].Text)
+ if response == "NO" || response == "UNSURE" {
+ slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
+ return fmt.Errorf("tool %s not approved for installation", cmd)
+ }
+
+ packageName := strings.TrimSpace(response)
+ if packageName == "" {
+ return fmt.Errorf("no package name provided for tool %s", cmd)
+ }
+
+ return b.installPackage(ctx, cmd, packageName, packageManager)
+}
+
+// installPackage handles the actual package installation
+func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
+ // Install the package (with update command first if needed)
+ // TODO: these invocations create zombies when we are PID 1.
+ // We should give them the same zombie-reaping treatment as above,
+ // if/when we care enough to put in the effort. Not today.
+ var updateCmd, installCmd string
+ switch packageManager {
+ case "apt", "apt-get":
+ updateCmd = fmt.Sprintf("sudo %s update", packageManager)
+ installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
+ case "brew":
+ // brew handles updates automatically, no explicit update needed
+ installCmd = fmt.Sprintf("brew install %s", packageName)
+ case "apk":
+ updateCmd = "sudo apk update"
+ installCmd = fmt.Sprintf("sudo apk add %s", packageName)
+ case "yum", "dnf":
+ // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
+ // and install will fetch the latest available packages
+ installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
+ case "pacman":
+ updateCmd = "sudo pacman -Sy"
+ installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
+ case "zypper":
+ updateCmd = "sudo zypper refresh"
+ installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
+ case "xbps-install":
+ updateCmd = "sudo xbps-install -S"
+ installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
+ case "emerge":
+ // Note: emerge --sync is expensive, so we skip it for JIT installs
+ // Users should manually sync if needed
+ installCmd = fmt.Sprintf("sudo emerge %s", packageName)
+ case "nix-env":
+ // nix-env doesn't require explicit updates for JIT installs
+ installCmd = fmt.Sprintf("nix-env -i %s", packageName)
+ case "guix":
+ // guix doesn't require explicit updates for JIT installs
+ installCmd = fmt.Sprintf("guix install %s", packageName)
+ case "pkg":
+ updateCmd = "sudo pkg update"
+ installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
+ case "slackpkg":
+ updateCmd = "sudo slackpkg update"
+ installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
+ default:
+ return fmt.Errorf("unsupported package manager: %s", packageManager)
+ }
+
+ slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
+
+ // Execute the update command first if needed
+ if updateCmd != "" {
+ slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
+ updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
+ updateOutput, err := updateCmdExec.CombinedOutput()
+ if err != nil {
+ slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
+ }
+ }
+
+ // Execute the install command
+ cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
+ output, err := cmdExec.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
+ }
+
+ slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
+ return nil
+}
+
+// selectBestLLM selects the best available LLM service for bash tool validation
+func (b *BashTool) selectBestLLM() (llm.Service, error) {
+ if b.LLMProvider == nil {
+ return nil, fmt.Errorf("no LLM provider available")
+ }
+
+ // Preferred models in order of preference for tool validation (fast, cheap models preferred)
+ preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
+
+ for _, model := range preferredModels {
+ svc, err := b.LLMProvider.GetService(model)
+ if err == nil {
+ return svc, nil
+ }
+ }
+
+ // If no preferred model is available, try any available model
+ available := b.LLMProvider.GetAvailableModels()
+ if len(available) > 0 {
+ return b.LLMProvider.GetService(available[0])
+ }
+
+ return nil, fmt.Errorf("no LLM services available")
+}
@@ -0,0 +1,570 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func TestBashSlowOk(t *testing.T) {
+ // Test that slow_ok flag is properly handled
+ t.Run("SlowOk Flag", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo 'slow test'","slow_ok":true}`)
+
+ bashTool := (&BashTool{WorkingDir: NewMutableWorkingDir("/")}).Tool()
+ toolOut := bashTool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ expected := "slow test\n"
+ if len(result) == 0 || result[0].Text != expected {
+ t.Errorf("Expected %q, got %q", expected, result[0].Text)
+ }
+ })
+
+ // Test that slow_ok with background works
+ t.Run("SlowOk with Background", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo 'slow background test'","slow_ok":true,"background":true}`)
+
+ bashTool := (&BashTool{WorkingDir: NewMutableWorkingDir("/")}).Tool()
+ toolOut := bashTool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // Should return background result XML-ish format
+ resultStr := result[0].Text
+ if !strings.Contains(resultStr, "<pid>") || !strings.Contains(resultStr, "<output_file>") {
+ t.Errorf("Expected XML-ish background result format, got: %s", resultStr)
+ }
+
+ // Extract PID and output file from XML-ish format for cleanup
+ // This is a simple extraction for test cleanup - in real usage the agent would parse this
+ lines := strings.Split(resultStr, "\n")
+ var outFile string
+ for _, line := range lines {
+ if strings.Contains(line, "<output_file>") {
+ start := strings.Index(line, "<output_file>") + len("<output_file>")
+ end := strings.Index(line, "</output_file>")
+ if end > start {
+ outFile = line[start:end]
+ }
+ break
+ }
+ }
+
+ if outFile != "" {
+ // Clean up
+ os.Remove(outFile)
+ os.Remove(filepath.Dir(outFile))
+ }
+ })
+}
+
+func TestBashTool(t *testing.T) {
+ bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+ tool := bashTool.Tool()
+
+ // Test basic functionality
+ t.Run("Basic Command", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo 'Hello, world!'"}`)
+
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ expected := "Hello, world!\n"
+ if len(result) == 0 || result[0].Text != expected {
+ t.Errorf("Expected %q, got %q", expected, result[0].Text)
+ }
+ })
+
+ // Test with arguments
+ t.Run("Command With Arguments", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"echo -n foo && echo -n bar"}`)
+
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ expected := "foobar"
+ if len(result) == 0 || result[0].Text != expected {
+ t.Errorf("Expected %q, got %q", expected, result[0].Text)
+ }
+ })
+
+ // Test with slow_ok parameter
+ t.Run("With SlowOK", func(t *testing.T) {
+ inputObj := struct {
+ Command string `json:"command"`
+ SlowOK bool `json:"slow_ok"`
+ }{
+ Command: "sleep 0.1 && echo 'Completed'",
+ SlowOK: true,
+ }
+ inputJSON, err := json.Marshal(inputObj)
+ if err != nil {
+ t.Fatalf("Failed to marshal input: %v", err)
+ }
+
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ expected := "Completed\n"
+ if len(result) == 0 || result[0].Text != expected {
+ t.Errorf("Expected %q, got %q", expected, result[0].Text)
+ }
+ })
+
+ // Test command timeout with custom timeout config
+ t.Run("Command Timeout", func(t *testing.T) {
+ // Use a custom BashTool with very short timeout
+ customTimeouts := &Timeouts{
+ Fast: 100 * time.Millisecond,
+ Slow: 100 * time.Millisecond,
+ Background: 100 * time.Millisecond,
+ }
+ customBash := &BashTool{
+ WorkingDir: NewMutableWorkingDir("/"),
+ Timeouts: customTimeouts,
+ }
+ tool := customBash.Tool()
+
+ input := json.RawMessage(`{"command":"sleep 0.5 && echo 'Should not see this'"}`)
+
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
+ t.Errorf("Expected timeout error, got none")
+ } else if !strings.Contains(toolOut.Error.Error(), "timed out") {
+ t.Errorf("Expected timeout error, got: %v", toolOut.Error)
+ }
+ })
+
+ // Test command that fails
+ t.Run("Failed Command", func(t *testing.T) {
+ input := json.RawMessage(`{"command":"exit 1"}`)
+
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
+ t.Errorf("Expected error for failed command, got none")
+ }
+ })
+
+ // Test invalid input
+ t.Run("Invalid JSON Input", func(t *testing.T) {
+ input := json.RawMessage(`{"command":123}`) // Invalid JSON (command must be string)
+
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
+ t.Errorf("Expected error for invalid input, got none")
+ }
+ })
+}
+
+func TestExecuteBash(t *testing.T) {
+ ctx := context.Background()
+ bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+
+ // Test successful command
+ t.Run("Successful Command", func(t *testing.T) {
+ req := bashInput{
+ Command: "echo 'Success'",
+ }
+
+ output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ want := "Success\n"
+ if output != want {
+ t.Errorf("Expected %q, got %q", want, output)
+ }
+ })
+
+ // Test SKETCH=1 environment variable is set
+ t.Run("SKETCH Environment Variable", func(t *testing.T) {
+ req := bashInput{
+ Command: "echo $SKETCH",
+ }
+
+ output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ want := "1\n"
+ if output != want {
+ t.Errorf("Expected SKETCH=1, got %q", output)
+ }
+ })
+
+ // Test command with output to stderr
+ t.Run("Command with stderr", func(t *testing.T) {
+ req := bashInput{
+ Command: "echo 'Error message' >&2 && echo 'Success'",
+ }
+
+ output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ want := "Error message\nSuccess\n"
+ if output != want {
+ t.Errorf("Expected %q, got %q", want, output)
+ }
+ })
+
+ // Test command that fails with stderr
+ t.Run("Failed Command with stderr", func(t *testing.T) {
+ req := bashInput{
+ Command: "echo 'Error message' >&2 && exit 1",
+ }
+
+ _, err := bashTool.executeBash(ctx, req, 5*time.Second)
+ if err == nil {
+ t.Errorf("Expected error for failed command, got none")
+ } else if !strings.Contains(err.Error(), "Error message") {
+ t.Errorf("Expected stderr in error message, got: %v", err)
+ }
+ })
+
+ // Test timeout
+ t.Run("Command Timeout", func(t *testing.T) {
+ req := bashInput{
+ Command: "sleep 1 && echo 'Should not see this'",
+ }
+
+ start := time.Now()
+ _, err := bashTool.executeBash(ctx, req, 100*time.Millisecond)
+ elapsed := time.Since(start)
+
+ // Command should time out after ~100ms, not wait for full 1 second
+ if elapsed >= 1*time.Second {
+ t.Errorf("Command did not respect timeout, took %v", elapsed)
+ }
+
+ if err == nil {
+ t.Errorf("Expected timeout error, got none")
+ } else if !strings.Contains(err.Error(), "timed out") {
+ t.Errorf("Expected timeout error, got: %v", err)
+ }
+ })
+}
+
+func TestBackgroundBash(t *testing.T) {
+ bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+ tool := bashTool.Tool()
+
+ // Test basic background execution
+ t.Run("Basic Background Command", func(t *testing.T) {
+ inputObj := struct {
+ Command string `json:"command"`
+ Background bool `json:"background"`
+ }{
+ Command: "echo 'Hello from background' $SKETCH",
+ Background: true,
+ }
+ inputJSON, err := json.Marshal(inputObj)
+ if err != nil {
+ t.Fatalf("Failed to marshal input: %v", err)
+ }
+
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // Parse the returned XML-ish format
+ resultStr := result[0].Text
+ if !strings.Contains(resultStr, "<pid>") || !strings.Contains(resultStr, "<output_file>") {
+ t.Fatalf("Expected XML-ish background result format, got: %s", resultStr)
+ }
+
+ // Extract PID and output file from XML-ish format
+ lines := strings.Split(resultStr, "\n")
+ var pidStr, outFile string
+ for _, line := range lines {
+ if strings.Contains(line, "<pid>") {
+ start := strings.Index(line, "<pid>") + len("<pid>")
+ end := strings.Index(line, "</pid>")
+ if end > start {
+ pidStr = line[start:end]
+ }
+ } else if strings.Contains(line, "<output_file>") {
+ start := strings.Index(line, "<output_file>") + len("<output_file>")
+ end := strings.Index(line, "</output_file>")
+ if end > start {
+ outFile = line[start:end]
+ }
+ }
+ }
+
+ // Verify we got valid values
+ if pidStr == "" || outFile == "" {
+ t.Errorf("Failed to extract PID or output file from result: %s", resultStr)
+ return
+ }
+
+ // Verify output file exists
+ if _, err := os.Stat(outFile); os.IsNotExist(err) {
+ t.Errorf("Output file doesn't exist: %s", outFile)
+ }
+
+ // Wait for the command output to be written to file
+ waitForFile(t, outFile)
+
+ // Check file contents
+ outputContent, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("Failed to read output file: %v", err)
+ }
+ // The implementation appends a completion message to the output
+ outputStr := string(outputContent)
+ if !strings.Contains(outputStr, "Hello from background 1") {
+ t.Errorf("Expected output to contain 'Hello from background 1', got %q", outputStr)
+ }
+ if !strings.Contains(outputStr, "[background process completed]") {
+ t.Errorf("Expected output to contain completion message, got %q", outputStr)
+ }
+
+ // Clean up
+ os.Remove(outFile)
+ os.Remove(filepath.Dir(outFile))
+ })
+
+ // Test background command with stderr output
+ t.Run("Background Command with stderr", func(t *testing.T) {
+ inputObj := struct {
+ Command string `json:"command"`
+ Background bool `json:"background"`
+ }{
+ Command: "echo 'Output to stdout' && echo 'Output to stderr' >&2",
+ Background: true,
+ }
+ inputJSON, err := json.Marshal(inputObj)
+ if err != nil {
+ t.Fatalf("Failed to marshal input: %v", err)
+ }
+
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // Parse the returned XML-ish format
+ resultStr := result[0].Text
+ lines := strings.Split(resultStr, "\n")
+ var outFile string
+ for _, line := range lines {
+ if strings.Contains(line, "<output_file>") {
+ start := strings.Index(line, "<output_file>") + len("<output_file>")
+ end := strings.Index(line, "</output_file>")
+ if end > start {
+ outFile = line[start:end]
+ }
+ break
+ }
+ }
+
+ // Wait for the command output to be written to file
+ waitForFile(t, outFile)
+
+ // Check output content (stdout and stderr are combined in implementation)
+ outputContent, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("Failed to read output file: %v", err)
+ }
+ // Implementation combines stdout and stderr into one file
+ outputStr := string(outputContent)
+ if !strings.Contains(outputStr, "Output to stdout") || !strings.Contains(outputStr, "Output to stderr") {
+ t.Errorf("Expected both stdout and stderr content, got %q", outputStr)
+ }
+
+ // Clean up
+ os.Remove(outFile)
+ os.Remove(filepath.Dir(outFile))
+ })
+
+ // Test background command running without waiting
+ t.Run("Background Command Running", func(t *testing.T) {
+ // Create a script that will continue running after we check
+ inputObj := struct {
+ Command string `json:"command"`
+ Background bool `json:"background"`
+ }{
+ Command: "echo 'Running in background' && sleep 5",
+ Background: true,
+ }
+ inputJSON, err := json.Marshal(inputObj)
+ if err != nil {
+ t.Fatalf("Failed to marshal input: %v", err)
+ }
+
+ // Start the command in the background
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // Parse the returned XML-ish format
+ resultStr := result[0].Text
+ lines := strings.Split(resultStr, "\n")
+ var pidStr, outFile string
+ for _, line := range lines {
+ if strings.Contains(line, "<pid>") {
+ start := strings.Index(line, "<pid>") + len("<pid>")
+ end := strings.Index(line, "</pid>")
+ if end > start {
+ pidStr = line[start:end]
+ }
+ } else if strings.Contains(line, "<output_file>") {
+ start := strings.Index(line, "<output_file>") + len("<output_file>")
+ end := strings.Index(line, "</output_file>")
+ if end > start {
+ outFile = line[start:end]
+ }
+ }
+ }
+
+ // Wait for the command output to be written to file
+ waitForFile(t, outFile)
+
+ // Check output content
+ outputContent, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("Failed to read output file: %v", err)
+ }
+ expectedOutput := "Running in background\n"
+ if string(outputContent) != expectedOutput {
+ t.Errorf("Expected output content %q, got %q", expectedOutput, string(outputContent))
+ }
+
+ // Verify the process is still running by parsing PID
+ if pidStr != "" {
+ // We can't easily test if the process is still running without importing strconv
+ // and the process might have finished by now anyway due to timing
+ t.Log("Process started in background with PID:", pidStr)
+ }
+
+ // Clean up
+ os.Remove(outFile)
+ os.Remove(filepath.Dir(outFile))
+ })
+}
+
+func TestBashTimeout(t *testing.T) {
+ // Test default timeout values
+ t.Run("Default Timeout Values", func(t *testing.T) {
+ // Test foreground default timeout
+ foreground := bashInput{
+ Command: "echo 'test'",
+ Background: false,
+ }
+ fgTimeout := foreground.timeout(nil)
+ expectedFg := 30 * time.Second
+ if fgTimeout != expectedFg {
+ t.Errorf("Expected foreground default timeout to be %v, got %v", expectedFg, fgTimeout)
+ }
+
+ // Test background default timeout
+ background := bashInput{
+ Command: "echo 'test'",
+ Background: true,
+ }
+ bgTimeout := background.timeout(nil)
+ expectedBg := 24 * time.Hour
+ if bgTimeout != expectedBg {
+ t.Errorf("Expected background default timeout to be %v, got %v", expectedBg, bgTimeout)
+ }
+
+ // Test slow_ok timeout
+ slowOk := bashInput{
+ Command: "echo 'test'",
+ Background: false,
+ SlowOK: true,
+ }
+ slowTimeout := slowOk.timeout(nil)
+ expectedSlow := 15 * time.Minute
+ if slowTimeout != expectedSlow {
+ t.Errorf("Expected slow_ok timeout to be %v, got %v", expectedSlow, slowTimeout)
+ }
+
+ // Test custom timeout config
+ customTimeouts := &Timeouts{
+ Fast: 5 * time.Second,
+ Slow: 2 * time.Minute,
+ Background: 1 * time.Hour,
+ }
+ customFast := bashInput{
+ Command: "echo 'test'",
+ Background: false,
+ }
+ customTimeout := customFast.timeout(customTimeouts)
+ expectedCustom := 5 * time.Second
+ if customTimeout != expectedCustom {
+ t.Errorf("Expected custom timeout to be %v, got %v", expectedCustom, customTimeout)
+ }
+ })
+}
+
+// waitForFile waits for a file to exist and be non-empty or times out
+func waitForFile(t *testing.T, filepath string) {
+ timeout := time.After(5 * time.Second)
+ tick := time.NewTicker(10 * time.Millisecond)
+ defer tick.Stop()
+
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("Timed out waiting for file to exist and have contents: %s", filepath)
+ return
+ case <-tick.C:
+ info, err := os.Stat(filepath)
+ if err == nil && info.Size() > 0 {
+ return // File exists and has content
+ }
+ }
+ }
+}
+
+// waitForProcessDeath waits for a process to no longer exist or times out
+func waitForProcessDeath(t *testing.T, pid int) {
+ timeout := time.After(5 * time.Second)
+ tick := time.NewTicker(50 * time.Millisecond)
+ defer tick.Stop()
+
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("Timed out waiting for process %d to exit", pid)
+ return
+ case <-tick.C:
+ process, _ := os.FindProcess(pid)
+ err := process.Signal(syscall.Signal(0))
+ if err != nil {
+ // Process doesn't exist
+ return
+ }
+ }
+ }
+}
@@ -0,0 +1,242 @@
+package bashkit
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "mvdan.cc/sh/v3/syntax"
+)
+
+var checks = []func(*syntax.CallExpr) error{
+ noBlindGitAdd,
+}
+
+// Process-level checks that track state across calls
+var processAwareChecks = []func(*syntax.CallExpr) error{
+ noSketchWipBranchChangesOnce,
+}
+
+// Track whether sketch-wip branch warning has been shown in this process
+var (
+ sketchWipWarningMu sync.Mutex
+ sketchWipWarningShown bool
+)
+
+// ResetSketchWipWarning resets the warning state for testing purposes
+func ResetSketchWipWarning() {
+ sketchWipWarningMu.Lock()
+ sketchWipWarningShown = false
+ sketchWipWarningMu.Unlock()
+}
+
+// Check inspects bashScript and returns an error if it ought not be executed.
+// Check DOES NOT PROVIDE SECURITY against malicious actors.
+// It is intended to catch straightforward mistakes in which a model
+// does things despite having been instructed not to do them.
+func Check(bashScript string) error {
+ r := strings.NewReader(bashScript)
+ parser := syntax.NewParser()
+ file, err := parser.Parse(r, "")
+ if err != nil {
+ // Execution will fail, but we'll get a better error message from bash.
+ // Note that if this were security load bearing, this would be a terrible idea:
+ // You could smuggle stuff past Check by exploiting differences in what is considered syntactically valid.
+ // But it is not.
+ return nil
+ }
+
+ syntax.Walk(file, func(node syntax.Node) bool {
+ if err != nil {
+ return false
+ }
+ callExpr, ok := node.(*syntax.CallExpr)
+ if !ok {
+ return true
+ }
+ // Run regular checks
+ for _, check := range checks {
+ err = check(callExpr)
+ if err != nil {
+ return false
+ }
+ }
+ // Run process-aware checks
+ for _, check := range processAwareChecks {
+ err = check(callExpr)
+ if err != nil {
+ return false
+ }
+ }
+ return true
+ })
+
+ return err
+}
+
+// WillRunGitCommit checks if the provided bash script will run 'git commit'.
+// It returns true if any command in the script is a git commit command.
+func WillRunGitCommit(bashScript string) (bool, error) {
+ r := strings.NewReader(bashScript)
+ parser := syntax.NewParser()
+ file, err := parser.Parse(r, "")
+ if err != nil {
+ // Parsing failed, but let's not consider this an error for the same reasons as in Check
+ return false, nil
+ }
+
+ willCommit := false
+
+ syntax.Walk(file, func(node syntax.Node) bool {
+ callExpr, ok := node.(*syntax.CallExpr)
+ if !ok {
+ return true
+ }
+ if isGitCommitCommand(callExpr) {
+ willCommit = true
+ return false
+ }
+ return true
+ })
+
+ return willCommit, nil
+}
+
+// noBlindGitAdd checks for git add commands that blindly add all files.
+// It rejects patterns like 'git add -A', 'git add .', 'git add --all', 'git add *'.
+func noBlindGitAdd(cmd *syntax.CallExpr) error {
+ if hasBlindGitAdd(cmd) {
+ return fmt.Errorf("permission denied: blind git add commands (git add -A, git add ., git add --all, git add *) are not allowed, specify files explicitly")
+ }
+ return nil
+}
+
+func hasBlindGitAdd(cmd *syntax.CallExpr) bool {
+ if len(cmd.Args) < 2 {
+ return false
+ }
+ if cmd.Args[0].Lit() != "git" {
+ return false
+ }
+
+ // Find the 'add' subcommand
+ addIndex := -1
+ for i, arg := range cmd.Args {
+ if arg.Lit() == "add" {
+ addIndex = i
+ break
+ }
+ }
+
+ if addIndex < 0 {
+ return false
+ }
+
+ // Check arguments after 'add' for blind patterns
+ for i := addIndex + 1; i < len(cmd.Args); i++ {
+ arg := cmd.Args[i].Lit()
+ // Check for blind add patterns
+ if arg == "-A" || arg == "--all" || arg == "." || arg == "*" {
+ return true
+ }
+ }
+
+ return false
+}
+
+// isGitCommitCommand checks if a command is 'git commit'.
+func isGitCommitCommand(cmd *syntax.CallExpr) bool {
+ if len(cmd.Args) < 2 {
+ return false
+ }
+
+ // First argument must be 'git'
+ if cmd.Args[0].Lit() != "git" {
+ return false
+ }
+
+ // Look for 'commit' in any position after 'git'
+ for i := 1; i < len(cmd.Args); i++ {
+ if cmd.Args[i].Lit() == "commit" {
+ return true
+ }
+ }
+
+ return false
+}
+
+// noSketchWipBranchChangesOnce checks for git commands that would change the sketch-wip branch.
+// It rejects commands that would rename the sketch-wip branch or switch away from it.
+// This check only shows the warning once per process.
+func noSketchWipBranchChangesOnce(cmd *syntax.CallExpr) error {
+ if hasSketchWipBranchChanges(cmd) {
+ // Check if we've already warned in this process
+ sketchWipWarningMu.Lock()
+ alreadyWarned := sketchWipWarningShown
+ if !alreadyWarned {
+ sketchWipWarningShown = true
+ }
+ sketchWipWarningMu.Unlock()
+
+ if !alreadyWarned {
+ return fmt.Errorf("permission denied: cannot leave 'sketch-wip' branch. This branch is designated for change detection and auto-push; work on other branches may be lost. Warning shown once per session. Repeat command if needed for temporary operations (rebase, bisect, etc.) but return to sketch-wip afterward. Note: users can push to any branch via the Push button in the UI")
+ }
+ }
+ return nil
+}
+
+// hasSketchWipBranchChanges checks if a git command would change the sketch-wip branch.
+func hasSketchWipBranchChanges(cmd *syntax.CallExpr) bool {
+ if len(cmd.Args) < 2 {
+ return false
+ }
+ if cmd.Args[0].Lit() != "git" {
+ return false
+ }
+
+ // Look for subcommands that could change the sketch-wip branch
+ for i := 1; i < len(cmd.Args); i++ {
+ arg := cmd.Args[i].Lit()
+ switch arg {
+ case "branch":
+ // Check for branch rename: git branch -m sketch-wip newname or git branch -M sketch-wip newname
+ if i+2 < len(cmd.Args) {
+ // Look for -m or -M flag
+ for j := i + 1; j < len(cmd.Args)-1; j++ {
+ flag := cmd.Args[j].Lit()
+ if flag == "-m" || flag == "-M" {
+ // Check if sketch-wip is the source branch
+ if cmd.Args[j+1].Lit() == "sketch-wip" {
+ return true
+ }
+ }
+ }
+ }
+ case "checkout":
+ // Check for branch switching: git checkout otherbranch
+ // But allow git checkout files/paths
+ if i+1 < len(cmd.Args) {
+ nextArg := cmd.Args[i+1].Lit()
+ // Skip if it's a flag
+ if !strings.HasPrefix(nextArg, "-") {
+ // This might be a branch checkout - we'll be conservative and warn
+ // unless it looks like a file path
+ if !strings.Contains(nextArg, "/") && !strings.Contains(nextArg, ".") {
+ return true
+ }
+ }
+ }
+ case "switch":
+ // Check for branch switching: git switch otherbranch
+ if i+1 < len(cmd.Args) {
+ nextArg := cmd.Args[i+1].Lit()
+ // Skip if it's a flag
+ if !strings.HasPrefix(nextArg, "-") {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
@@ -0,0 +1,484 @@
+package bashkit
+
+import (
+ "strings"
+ "testing"
+
+ "mvdan.cc/sh/v3/syntax"
+)
+
+func TestCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantErr bool
+ errMatch string // string to match in error message, if wantErr is true
+ }{
+ {
+ name: "valid script",
+ script: "echo hello world",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "invalid syntax",
+ script: "echo 'unterminated string",
+ wantErr: false, // As per implementation, syntax errors are not flagged
+ errMatch: "",
+ },
+ // Git add validation tests
+ {
+ name: "git add with -A flag",
+ script: "git add -A",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with --all flag",
+ script: "git add --all",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with dot",
+ script: "git add .",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with asterisk",
+ script: "git add *",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with multiple flags including -A",
+ script: "git add -v -A",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with specific file",
+ script: "git add main.go",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "git add with multiple specific files",
+ script: "git add main.go utils.go",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "git add with directory path",
+ script: "git add src/main.go",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "git add with git flags before add",
+ script: "git -C /path/to/repo add -A",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with valid flags",
+ script: "git add -v main.go",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "git command without add",
+ script: "git status",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "multiline script with blind git add",
+ script: "echo 'Adding files' && git add -A && git commit -m 'Update'",
+ wantErr: true,
+ errMatch: "blind git add commands",
+ },
+ {
+ name: "git add with pattern that looks like blind but is specific",
+ script: "git add file.A",
+ wantErr: false,
+ errMatch: "",
+ },
+ {
+ name: "commented blind git add",
+ script: "# git add -A",
+ wantErr: false,
+ errMatch: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := Check(tc.script)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+ return
+ }
+ if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
+ t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
+ }
+ })
+ }
+}
+
+func TestWillRunGitCommit(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantCommit bool
+ }{
+ {
+ name: "simple git commit",
+ script: "git commit -m 'Add feature'",
+ wantCommit: true,
+ },
+ {
+ name: "git command without commit",
+ script: "git status",
+ wantCommit: false,
+ },
+ {
+ name: "multiline script with git commit",
+ script: "echo 'Making changes' && git add . && git commit -m 'Update files'",
+ wantCommit: true,
+ },
+ {
+ name: "multiline script without git commit",
+ script: "echo 'Checking status' && git status",
+ wantCommit: false,
+ },
+ {
+ name: "script with commented git commit",
+ script: "# git commit -m 'This is commented out'",
+ wantCommit: false,
+ },
+ {
+ name: "git commit with variables",
+ script: "MSG='Fix bug' && git commit -m 'Using variable'",
+ wantCommit: true,
+ },
+ {
+ name: "only git command",
+ script: "git",
+ wantCommit: false,
+ },
+ {
+ name: "script with invalid syntax",
+ script: "git commit -m 'unterminated string",
+ wantCommit: false,
+ },
+ {
+ name: "commit used in different context",
+ script: "echo 'commit message'",
+ wantCommit: false,
+ },
+ {
+ name: "git with flags before commit",
+ script: "git -C /path/to/repo commit -m 'Update'",
+ wantCommit: true,
+ },
+ {
+ name: "git with multiple flags",
+ script: "git --git-dir=.git -C repo commit -a -m 'Update'",
+ wantCommit: true,
+ },
+ {
+ name: "git with env vars",
+ script: "GIT_AUTHOR_NAME=\"Josh Bleecher Snyder\" GIT_AUTHOR_EMAIL=\"josharian@gmail.com\" git commit -am \"Updated code\"",
+ wantCommit: true,
+ },
+ {
+ name: "git with redirections",
+ script: "git commit -m 'Fix issue' > output.log 2>&1",
+ wantCommit: true,
+ },
+ {
+ name: "git with piped commands",
+ script: "echo 'Committing' | git commit -F -",
+ wantCommit: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotCommit, err := WillRunGitCommit(tc.script)
+ if err != nil {
+ t.Errorf("WillRunGitCommit() error = %v", err)
+ return
+ }
+ if gotCommit != tc.wantCommit {
+ t.Errorf("WillRunGitCommit() = %v, want %v", gotCommit, tc.wantCommit)
+ }
+ })
+ }
+}
+
+func TestSketchWipBranchProtection(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantErr bool
+ errMatch string
+ resetBefore bool // if true, reset warning state before test
+ }{
+ {
+ name: "git branch rename sketch-wip",
+ script: "git branch -m sketch-wip new-branch",
+ wantErr: true,
+ errMatch: "cannot leave 'sketch-wip' branch",
+ resetBefore: true,
+ },
+ {
+ name: "git branch force rename sketch-wip",
+ script: "git branch -M sketch-wip new-branch",
+ wantErr: false, // second call should not error (already warned)
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git checkout to other branch",
+ script: "git checkout main",
+ wantErr: false, // third call should not error (already warned)
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git switch to other branch",
+ script: "git switch main",
+ wantErr: false, // fourth call should not error (already warned)
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git checkout file (should be allowed)",
+ script: "git checkout -- file.txt",
+ wantErr: false,
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git checkout path (should be allowed)",
+ script: "git checkout -- src/main.go",
+ wantErr: false,
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git commit (should be allowed)",
+ script: "git commit -m 'test'",
+ wantErr: false,
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git status (should be allowed)",
+ script: "git status",
+ wantErr: false,
+ errMatch: "",
+ resetBefore: false,
+ },
+ {
+ name: "git branch rename other branch (should be allowed)",
+ script: "git branch -m old-branch new-branch",
+ wantErr: false,
+ errMatch: "",
+ resetBefore: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.resetBefore {
+ ResetSketchWipWarning()
+ }
+ err := Check(tc.script)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+ return
+ }
+ if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
+ t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
+ }
+ })
+ }
+}
+
+func TestHasSketchWipBranchChanges(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantHas bool
+ }{
+ {
+ name: "git branch rename sketch-wip",
+ script: "git branch -m sketch-wip new-branch",
+ wantHas: true,
+ },
+ {
+ name: "git branch force rename sketch-wip",
+ script: "git branch -M sketch-wip new-branch",
+ wantHas: true,
+ },
+ {
+ name: "git checkout to branch",
+ script: "git checkout main",
+ wantHas: true,
+ },
+ {
+ name: "git switch to branch",
+ script: "git switch main",
+ wantHas: true,
+ },
+ {
+ name: "git checkout file",
+ script: "git checkout -- file.txt",
+ wantHas: false,
+ },
+ {
+ name: "git checkout path",
+ script: "git checkout src/main.go",
+ wantHas: false,
+ },
+ {
+ name: "git checkout with .extension",
+ script: "git checkout file.go",
+ wantHas: false,
+ },
+ {
+ name: "git status",
+ script: "git status",
+ wantHas: false,
+ },
+ {
+ name: "git commit",
+ script: "git commit -m 'test'",
+ wantHas: false,
+ },
+ {
+ name: "git branch rename other",
+ script: "git branch -m old-branch new-branch",
+ wantHas: false,
+ },
+ {
+ name: "git switch with flag",
+ script: "git switch -c new-branch",
+ wantHas: false,
+ },
+ {
+ name: "git checkout with flag",
+ script: "git checkout -b new-branch",
+ wantHas: false,
+ },
+ {
+ name: "not a git command",
+ script: "echo hello",
+ wantHas: false,
+ },
+ {
+ name: "empty command",
+ script: "",
+ wantHas: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ r := strings.NewReader(tc.script)
+ parser := syntax.NewParser()
+ file, err := parser.Parse(r, "")
+ if err != nil {
+ if tc.wantHas {
+ t.Errorf("Parse error: %v", err)
+ }
+ return
+ }
+
+ found := false
+ syntax.Walk(file, func(node syntax.Node) bool {
+ callExpr, ok := node.(*syntax.CallExpr)
+ if !ok {
+ return true
+ }
+ if hasSketchWipBranchChanges(callExpr) {
+ found = true
+ return false
+ }
+ return true
+ })
+
+ if found != tc.wantHas {
+ t.Errorf("hasSketchWipBranchChanges() = %v, want %v", found, tc.wantHas)
+ }
+ })
+ }
+}
+
+func TestEdgeCases(t *testing.T) {
+ tests := []struct {
+ name string
+ script string
+ wantErr bool
+ resetBefore bool // if true, reset warning state before test
+ }{
+ {
+ name: "git branch -m with current branch to sketch-wip (should be allowed)",
+ script: "git branch -m current-branch sketch-wip",
+ wantErr: false,
+ resetBefore: true,
+ },
+ {
+ name: "git branch -m sketch-wip with no destination (should be blocked)",
+ script: "git branch -m sketch-wip",
+ wantErr: true,
+ resetBefore: true,
+ },
+ {
+ name: "git branch -M with current branch to sketch-wip (should be allowed)",
+ script: "git branch -M current-branch sketch-wip",
+ wantErr: false,
+ resetBefore: true,
+ },
+ {
+ name: "git checkout with -- flags (should be allowed)",
+ script: "git checkout -- --weird-filename",
+ wantErr: false,
+ resetBefore: true,
+ },
+ {
+ name: "git switch with create flag (should be allowed)",
+ script: "git switch --create new-branch",
+ wantErr: false,
+ resetBefore: true,
+ },
+ {
+ name: "complex git command with sketch-wip rename",
+ script: "git add . && git commit -m \"test\" && git branch -m sketch-wip production",
+ wantErr: true,
+ resetBefore: true,
+ },
+ {
+ name: "git switch with -c short form (should be allowed)",
+ script: "git switch -c feature-branch",
+ wantErr: false,
+ resetBefore: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.resetBefore {
+ ResetSketchWipWarning()
+ }
+ err := Check(tc.script)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+ }
+ })
+ }
+}
@@ -0,0 +1,67 @@
+package bashkit
+
+import (
+ "fmt"
+ "strings"
+
+ "mvdan.cc/sh/v3/interp"
+ "mvdan.cc/sh/v3/syntax"
+)
+
+// ExtractCommands parses a bash command and extracts individual command names that are
+// candidates for auto-installation.
+//
+// Returns only simple command names (no paths, no builtins, no variable assignments)
+// that could potentially be missing tools that need installation.
+//
+// Filtering logic:
+// - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh)
+// - Excludes shell builtins (echo, cd, test, [, etc.)
+// - Excludes variable assignments (FOO=bar)
+// - Deduplicates repeated command names
+//
+// Examples:
+//
+// "ls -la && echo done" β ["ls"] (echo filtered as builtin)
+// "./deploy.sh && curl api.com" β ["curl"] (./deploy.sh filtered as path)
+// "yamllint config.yaml" β ["yamllint"] (candidate for installation)
+func ExtractCommands(command string) ([]string, error) {
+ r := strings.NewReader(command)
+ parser := syntax.NewParser()
+ file, err := parser.Parse(r, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse bash command: %w", err)
+ }
+
+ var commands []string
+ seen := make(map[string]bool)
+
+ syntax.Walk(file, func(node syntax.Node) bool {
+ callExpr, ok := node.(*syntax.CallExpr)
+ if !ok || len(callExpr.Args) == 0 {
+ return true
+ }
+ cmdName := callExpr.Args[0].Lit()
+ if cmdName == "" {
+ return true
+ }
+ if strings.Contains(cmdName, "=") {
+ // variable assignment
+ return true
+ }
+ if strings.Contains(cmdName, "/") {
+ // commands with slashes are user-specified executables/scripts
+ return true
+ }
+ if interp.IsBuiltin(cmdName) {
+ return true
+ }
+ if !seen[cmdName] {
+ seen[cmdName] = true
+ commands = append(commands, cmdName)
+ }
+ return true
+ })
+
+ return commands, nil
+}
@@ -0,0 +1,146 @@
+package bashkit
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestExtractCommands(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "simple command",
+ input: "ls -la",
+ expected: []string{"ls"},
+ },
+ {
+ name: "command with pipe",
+ input: "ls -la | grep test",
+ expected: []string{"ls", "grep"},
+ },
+ {
+ name: "command with logical and (builtin filtered)",
+ input: "mkdir test && cd test",
+ expected: []string{"mkdir"}, // cd is builtin, filtered out
+ },
+ {
+ name: "if statement with commands (builtin filtered)",
+ input: "if [ -f file.txt ]; then cat file.txt; fi",
+ expected: []string{"cat"}, // [ is builtin, filtered out
+ },
+ {
+ name: "variable assignment with command (builtin filtered)",
+ input: "FOO=bar echo $FOO",
+ expected: []string{}, // echo is builtin, filtered out
+ },
+ {
+ name: "script path filtered out (builtin also filtered)",
+ input: "./script.sh && echo done",
+ expected: []string{}, // echo is builtin, filtered out
+ },
+ {
+ name: "multiline script (builtin filtered)",
+ input: "python3 -c 'print(\"hello\")'\necho 'done'",
+ expected: []string{"python3"}, // echo is builtin, filtered out
+ },
+ {
+ name: "complex command chain (builtin filtered)",
+ input: "curl -s https://api.github.com | jq '.name' && echo 'done'",
+ expected: []string{"curl", "jq"}, // echo is builtin, filtered out
+ },
+ {
+ name: "builtins filtered out",
+ input: "echo 'test' && true && ls",
+ expected: []string{"ls"},
+ },
+ {
+ name: "empty command",
+ input: "",
+ expected: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := ExtractCommands(tt.input)
+ if err != nil {
+ t.Fatalf("ExtractCommands() error = %v", err)
+ }
+ // Handle empty slice comparison
+ if len(result) == 0 && len(tt.expected) == 0 {
+ return // Both are empty, test passes
+ }
+ if !reflect.DeepEqual(result, tt.expected) {
+ t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestExtractCommandsErrorHandling(t *testing.T) {
+ // Test with syntactically invalid bash
+ invalidBash := "if [ incomplete"
+ _, err := ExtractCommands(invalidBash)
+ if err == nil {
+ t.Error("ExtractCommands() should return error for invalid bash syntax")
+ }
+}
+
+func TestExtractCommandsPathFiltering(t *testing.T) {
+ // Test that commands with paths are properly filtered out during extraction
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "relative script path filtered (builtin also filtered)",
+ input: "./my-script.sh && echo 'done'",
+ expected: []string{}, // echo is builtin, filtered out
+ },
+ {
+ name: "absolute path filtered",
+ input: "/usr/bin/custom-tool --help",
+ expected: []string{},
+ },
+ {
+ name: "parent directory script filtered",
+ input: "../scripts/build.sh",
+ expected: []string{},
+ },
+ {
+ name: "home directory path filtered",
+ input: "~/.local/bin/tool",
+ expected: []string{},
+ },
+ {
+ name: "simple commands without paths included",
+ input: "curl https://example.com | jq '.name'",
+ expected: []string{"curl", "jq"},
+ },
+ {
+ name: "mixed paths and simple commands",
+ input: "./setup.sh && python3 -c 'print(\"hello\")' && /bin/ls",
+ expected: []string{"python3"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := ExtractCommands(tt.input)
+ if err != nil {
+ t.Fatalf("ExtractCommands() error = %v", err)
+ }
+ // Handle empty slice comparison
+ if len(result) == 0 && len(tt.expected) == 0 {
+ return // Both are empty, test passes
+ }
+ if !reflect.DeepEqual(result, tt.expected) {
+ t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
@@ -0,0 +1,113 @@
+# Browser Tools for Claude
+
+This package provides a set of tools that allow Claude to control a headless
+Chrome browser from Go. The tools are built using the
+[chromedp](https://github.com/chromedp/chromedp) library.
+
+## Available Tools
+
+1. `browser_navigate` - Navigate to a URL and wait for the page to load
+2. `browser_eval` - Evaluate JavaScript in the browser context
+3. `browser_screenshot` - Take a screenshot of the page or a specific element
+
+## Usage
+
+```go
+// Create a context
+ctx := context.Background()
+
+// Register browser tools and get a cleanup function
+tools, cleanup := browse.RegisterBrowserTools(ctx)
+defer cleanup() // Important: always call cleanup to release browser resources
+
+// Add tools to your agent
+for _, tool := range tools {
+ agent.AddTool(tool)
+}
+```
+
+## Requirements
+
+- Chrome or Chromium must be installed on the system
+- In Docker environments, the multi-stage build automatically provides headless-shell from chromedp/headless-shell
+- For local development, install Chrome/Chromium manually
+- The `chromedp` package handles launching and controlling the browser
+
+## Tool Input/Output
+
+All tools follow a standard JSON input/output format. For example:
+
+**Navigate Tool Input:**
+```json
+{
+ "url": "https://example.com"
+}
+```
+
+**Navigate Tool Output (success):**
+```json
+{
+ "status": "success"
+}
+```
+
+**Tool Output (error):**
+```json
+{
+ "status": "error",
+ "error": "Error message"
+}
+```
+
+## Example Tool Usage
+
+```go
+// Example of using the navigate tool directly
+navTool := tools[0] // Get browser_navigate tool
+input := map[string]string{"url": "https://example.com"}
+inputJSON, _ := json.Marshal(input)
+
+// Call the tool
+result, err := navTool.Run(ctx, json.RawMessage(inputJSON))
+if err != nil {
+ log.Fatalf("Error: %v", err)
+}
+fmt.Println(result)
+```
+
+## Screenshot Storage
+
+The browser screenshot tool has been modified to save screenshots to a temporary directory and identify them by ID, rather than returning base64-encoded data directly. This improves efficiency by:
+
+1. Reducing token usage in LLM responses
+2. Avoiding encoding/decoding overhead
+3. Allowing for larger screenshots without message size limitations
+
+### How It Works
+
+1. When a screenshot is taken, it's saved to `/tmp/shelley-screenshots/` with a unique UUID filename
+2. The tool returns the screenshot ID in its response
+3. The web UI can fetch the screenshot using the `/api/read?path=...` endpoint (with path set to the screenshot file)
+
+### Example Usage
+
+Agent calls the screenshot tool:
+```json
+{
+ "id": "tool_call_123",
+ "name": "browser_screenshot",
+ "params": {}
+}
+```
+
+Tool response:
+```json
+{
+ "id": "tool_call_123",
+ "result": {
+ "id": "550e8400-e29b-41d4-a716-446655440000"
+ }
+}
+```
+
+The screenshot is then accessible at: `/api/read?path=/tmp/shelley-screenshots/550e8400-e29b-41d4-a716-446655440000.png`
@@ -0,0 +1,722 @@
+// Package browse provides browser automation tools for the agent
+package browse
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+ "github.com/google/uuid"
+ "shelley.exe.dev/llm"
+)
+
+// ScreenshotDir is the directory where screenshots are stored
+const ScreenshotDir = "/tmp/shelley-screenshots"
+
+// DefaultIdleTimeout is how long to wait before shutting down an idle browser
+const DefaultIdleTimeout = 30 * time.Minute
+
+// BrowseTools contains all browser tools and manages a shared browser instance
+type BrowseTools struct {
+ ctx context.Context
+ allocCtx context.Context
+ allocCancel context.CancelFunc
+ browserCtx context.Context
+ browserCtxCancel context.CancelFunc
+ mux sync.Mutex
+ // Map to track screenshots by ID and their creation time
+ screenshots map[string]time.Time
+ screenshotsMutex sync.Mutex
+ // Console logs storage
+ consoleLogs []*runtime.EventConsoleAPICalled
+ consoleLogsMutex sync.Mutex
+ maxConsoleLogs int
+ // Idle timeout management
+ idleTimeout time.Duration
+ idleTimer *time.Timer
+}
+
+// NewBrowseTools creates a new set of browser automation tools
+func NewBrowseTools(ctx context.Context) *BrowseTools {
+ return NewBrowseToolsWithIdleTimeout(ctx, DefaultIdleTimeout)
+}
+
+// NewBrowseToolsWithIdleTimeout creates browser tools with a custom idle timeout
+func NewBrowseToolsWithIdleTimeout(ctx context.Context, idleTimeout time.Duration) *BrowseTools {
+ // Ensure the screenshot directory exists
+ if err := os.MkdirAll(ScreenshotDir, 0o755); err != nil {
+ log.Printf("Failed to create screenshot directory: %v", err)
+ }
+
+ return &BrowseTools{
+ ctx: ctx,
+ screenshots: make(map[string]time.Time),
+ consoleLogs: make([]*runtime.EventConsoleAPICalled, 0),
+ maxConsoleLogs: 100,
+ idleTimeout: idleTimeout,
+ }
+}
+
+// GetBrowserContext returns the browser context, initializing if needed and resetting the idle timer.
+func (b *BrowseTools) GetBrowserContext() (context.Context, error) {
+ b.mux.Lock()
+ defer b.mux.Unlock()
+
+ // If browser exists, reset idle timer and return
+ if b.browserCtx != nil {
+ b.resetIdleTimerLocked()
+ return b.browserCtx, nil
+ }
+
+ // Initialize a new browser
+ opts := chromedp.DefaultExecAllocatorOptions[:]
+ opts = append(opts, chromedp.NoSandbox)
+ opts = append(opts, chromedp.Flag("--disable-dbus", true))
+ opts = append(opts, chromedp.WSURLReadTimeout(60*time.Second))
+
+ allocCtx, allocCancel := chromedp.NewExecAllocator(b.ctx, opts...)
+ browserCtx, browserCancel := chromedp.NewContext(
+ allocCtx,
+ chromedp.WithLogf(log.Printf),
+ chromedp.WithErrorf(log.Printf),
+ chromedp.WithBrowserOption(chromedp.WithDialTimeout(60*time.Second)),
+ )
+
+ // Set up console log listener
+ chromedp.ListenTarget(browserCtx, func(ev any) {
+ if e, ok := ev.(*runtime.EventConsoleAPICalled); ok {
+ b.captureConsoleLog(e)
+ }
+ })
+
+ // Start the browser
+ if err := chromedp.Run(browserCtx); err != nil {
+ allocCancel()
+ return nil, fmt.Errorf("failed to start browser (please apt get chromium or equivalent): %w", err)
+ }
+
+ // Set default viewport size to 1280x720 (16:9 widescreen)
+ if err := chromedp.Run(browserCtx, chromedp.EmulateViewport(1280, 720)); err != nil {
+ browserCancel()
+ allocCancel()
+ return nil, fmt.Errorf("failed to set default viewport: %w", err)
+ }
+
+ b.allocCtx = allocCtx
+ b.allocCancel = allocCancel
+ b.browserCtx = browserCtx
+ b.browserCtxCancel = browserCancel
+
+ b.resetIdleTimerLocked()
+
+ return b.browserCtx, nil
+}
+
+// resetIdleTimerLocked resets or starts the idle timer. Caller must hold b.mux.
+func (b *BrowseTools) resetIdleTimerLocked() {
+ if b.idleTimer != nil {
+ b.idleTimer.Stop()
+ }
+ b.idleTimer = time.AfterFunc(b.idleTimeout, b.idleShutdown)
+}
+
+// idleShutdown is called when the idle timer fires
+func (b *BrowseTools) idleShutdown() {
+ b.mux.Lock()
+ defer b.mux.Unlock()
+
+ if b.browserCtx == nil {
+ return
+ }
+
+ log.Printf("Browser idle for %v, shutting down", b.idleTimeout)
+ b.closeBrowserLocked()
+}
+
+// closeBrowserLocked shuts down the browser. Caller must hold b.mux.
+func (b *BrowseTools) closeBrowserLocked() {
+ if b.idleTimer != nil {
+ b.idleTimer.Stop()
+ b.idleTimer = nil
+ }
+
+ if b.browserCtxCancel != nil {
+ b.browserCtxCancel()
+ b.browserCtxCancel = nil
+ }
+
+ if b.allocCancel != nil {
+ b.allocCancel()
+ b.allocCancel = nil
+ }
+
+ b.browserCtx = nil
+ b.allocCtx = nil
+}
+
+// Close shuts down the browser
+func (b *BrowseTools) Close() {
+ b.mux.Lock()
+ defer b.mux.Unlock()
+ b.closeBrowserLocked()
+}
+
+// NavigateTool definition
+type navigateInput struct {
+ URL string `json:"url"`
+ Timeout string `json:"timeout,omitempty"`
+}
+
+// isPort80 reports whether urlStr definitely uses port 80.
+func isPort80(urlStr string) bool {
+ parsedURL, err := url.Parse(urlStr)
+ if err != nil {
+ return false
+ }
+ port := parsedURL.Port()
+ return port == "80" || (port == "" && parsedURL.Scheme == "http")
+}
+
+// NewNavigateTool creates a tool for navigating to URLs
+func (b *BrowseTools) NewNavigateTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_navigate",
+ Description: "Navigate the browser to a specific URL and wait for page to load",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "The URL to navigate to"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string (default: 15s)"
+ }
+ },
+ "required": ["url"]
+ }`),
+ Run: b.navigateRun,
+ }
+}
+
+func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input navigateInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ if isPort80(input.URL) {
+ return llm.ErrorToolOut(fmt.Errorf("port 80 is not the port you're looking for--port 80 is the main sketch server"))
+ }
+
+ browserCtx, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Create a timeout context for this operation
+ timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+ defer cancel()
+
+ err = chromedp.Run(timeoutCtx,
+ chromedp.Navigate(input.URL),
+ chromedp.WaitReady("body"),
+ )
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
+}
+
+// ResizeTool definition
+type resizeInput struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Timeout string `json:"timeout,omitempty"`
+}
+
+// NewResizeTool creates a tool for resizing the browser viewport
+func (b *BrowseTools) NewResizeTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_resize",
+ Description: "Resize the browser viewport to a specific width and height",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "width": {
+ "type": "integer",
+ "description": "Viewport width in pixels"
+ },
+ "height": {
+ "type": "integer",
+ "description": "Viewport height in pixels"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string (default: 15s)"
+ }
+ },
+ "required": ["width", "height"]
+ }`),
+ Run: b.resizeRun,
+ }
+}
+
+func (b *BrowseTools) resizeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input resizeInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ if input.Width <= 0 || input.Height <= 0 {
+ return llm.ErrorToolOut(fmt.Errorf("invalid dimensions: width and height must be positive"))
+ }
+
+ browserCtx, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+ defer cancel()
+
+ err = chromedp.Run(timeoutCtx,
+ chromedp.EmulateViewport(int64(input.Width), int64(input.Height)),
+ )
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
+}
+
+// EvalTool definition
+type evalInput struct {
+ Expression string `json:"expression"`
+ Timeout string `json:"timeout,omitempty"`
+ Await *bool `json:"await,omitempty"`
+}
+
+// NewEvalTool creates a tool for evaluating JavaScript
+func (b *BrowseTools) NewEvalTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_eval",
+ Description: `Evaluate JavaScript in the browser context.
+Your go-to tool for interacting with content: clicking buttons, typing, getting content, scrolling, resizing, waiting for content/selector to be ready, etc.`,
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "expression": {
+ "type": "string",
+ "description": "JavaScript expression to evaluate"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string (default: 15s)"
+ },
+ "await": {
+ "type": "boolean",
+ "description": "If true, wait for promises to resolve and return their resolved value (default: true)"
+ }
+ },
+ "required": ["expression"]
+ }`),
+ Run: b.evalRun,
+ }
+}
+
+func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input evalInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ browserCtx, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Create a timeout context for this operation
+ timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+ defer cancel()
+
+ var result any
+ var evalOps []chromedp.EvaluateOption
+
+ await := true
+ if input.Await != nil {
+ await = *input.Await
+ }
+ if await {
+ evalOps = append(evalOps, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+ return p.WithAwaitPromise(true)
+ })
+ }
+
+ evalAction := chromedp.Evaluate(input.Expression, &result, evalOps...)
+
+ err = chromedp.Run(timeoutCtx, evalAction)
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Return the result as JSON
+ response, err := json.Marshal(result)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to marshal response: %w", err)
+ }
+
+ return llm.ToolOut{LLMContent: llm.TextContent("<javascript_result>" + string(response) + "</javascript_result>")}
+}
+
+// ScreenshotTool definition
+type screenshotInput struct {
+ Selector string `json:"selector,omitempty"`
+ Timeout string `json:"timeout,omitempty"`
+}
+
+// NewScreenshotTool creates a tool for taking screenshots
+func (b *BrowseTools) NewScreenshotTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_take_screenshot",
+ Description: "Take a screenshot of the page or a specific element",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "selector": {
+ "type": "string",
+ "description": "CSS selector for the element to screenshot (optional)"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string (default: 15s)"
+ }
+ }
+ }`),
+ Run: b.screenshotRun,
+ }
+}
+
+func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input screenshotInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ // Try to get a browser context; if unavailable, return an error
+ browserCtx, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Create a timeout context for this operation
+ timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+ defer cancel()
+
+ var buf []byte
+ var actions []chromedp.Action
+
+ if input.Selector != "" {
+ // Take screenshot of specific element
+ actions = append(actions,
+ chromedp.WaitReady(input.Selector),
+ chromedp.Screenshot(input.Selector, &buf, chromedp.NodeVisible),
+ )
+ } else {
+ // Take full page screenshot
+ actions = append(actions, chromedp.CaptureScreenshot(&buf))
+ }
+
+ err = chromedp.Run(timeoutCtx, actions...)
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Save the screenshot and get its ID for potential future reference
+ id := b.SaveScreenshot(buf)
+ if id == "" {
+ return llm.ErrorToolOut(fmt.Errorf("failed to save screenshot"))
+ }
+
+ // Get the full path to the screenshot
+ screenshotPath := GetScreenshotPath(id)
+
+ // Encode the image as base64
+ base64Data := base64.StdEncoding.EncodeToString(buf)
+
+ // Prepare display data for the UI
+ display := map[string]any{
+ "type": "screenshot",
+ "id": id,
+ "url": "/api/read?path=" + url.QueryEscape(screenshotPath),
+ "path": screenshotPath,
+ "selector": input.Selector,
+ }
+
+ // Return the screenshot directly to the LLM and provide display metadata for the UI
+ return llm.ToolOut{LLMContent: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: fmt.Sprintf("Screenshot taken (saved as %s)", screenshotPath),
+ },
+ {
+ Type: llm.ContentTypeText, // Will be mapped to image in content array
+ MediaType: "image/png",
+ Data: base64Data,
+ },
+ }, Display: display}
+}
+
+// GetTools returns browser tools, optionally filtering out screenshot-related tools
+func (b *BrowseTools) GetTools(includeScreenshotTools bool) []*llm.Tool {
+ tools := []*llm.Tool{
+ b.NewNavigateTool(),
+ b.NewEvalTool(),
+ b.NewResizeTool(),
+ b.NewRecentConsoleLogsTool(),
+ b.NewClearConsoleLogsTool(),
+ }
+
+ // Add screenshot-related tools if supported
+ if includeScreenshotTools {
+ tools = append(tools, b.NewScreenshotTool())
+ tools = append(tools, b.NewReadImageTool())
+ }
+
+ return tools
+}
+
+// SaveScreenshot saves a screenshot to disk and returns its ID
+func (b *BrowseTools) SaveScreenshot(data []byte) string {
+ // Generate a unique ID
+ id := uuid.New().String()
+
+ // Save the file
+ filePath := filepath.Join(ScreenshotDir, id+".png")
+ if err := os.WriteFile(filePath, data, 0o644); err != nil {
+ log.Printf("Failed to save screenshot: %v", err)
+ return ""
+ }
+
+ // Track this screenshot
+ b.screenshotsMutex.Lock()
+ b.screenshots[id] = time.Now()
+ b.screenshotsMutex.Unlock()
+
+ return id
+}
+
+// GetScreenshotPath returns the full path to a screenshot by ID
+func GetScreenshotPath(id string) string {
+ return filepath.Join(ScreenshotDir, id+".png")
+}
+
+// ReadImageTool definition
+type readImageInput struct {
+ Path string `json:"path"`
+ Timeout string `json:"timeout,omitempty"`
+}
+
+// NewReadImageTool creates a tool for reading images and returning them as base64 encoded data
+func (b *BrowseTools) NewReadImageTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "read_image",
+ Description: "Read an image file (such as a screenshot) and encode it for sending to the LLM",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the image file to read"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string (default: 15s)"
+ }
+ },
+ "required": ["path"]
+ }`),
+ Run: b.readImageRun,
+ }
+}
+
+func (b *BrowseTools) readImageRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input readImageInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ // Check if the path exists
+ if _, err := os.Stat(input.Path); os.IsNotExist(err) {
+ return llm.ErrorfToolOut("image file not found: %s", input.Path)
+ }
+
+ // Read the file
+ imageData, err := os.ReadFile(input.Path)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to read image file: %w", err)
+ }
+
+ // Detect the image type
+ imageType := http.DetectContentType(imageData)
+ if !strings.HasPrefix(imageType, "image/") {
+ return llm.ErrorfToolOut("file is not an image: %s", imageType)
+ }
+
+ // Encode the image as base64
+ base64Data := base64.StdEncoding.EncodeToString(imageData)
+
+ // Create a Content object that includes both text and the image
+ return llm.ToolOut{LLMContent: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: fmt.Sprintf("Image from %s (type: %s)", input.Path, imageType),
+ },
+ {
+ Type: llm.ContentTypeText, // Will be mapped to image in content array
+ MediaType: imageType,
+ Data: base64Data,
+ },
+ }}
+}
+
+// parseTimeout parses a timeout string and returns a time.Duration
+// It returns a default of 5 seconds if the timeout is empty or invalid
+func parseTimeout(timeout string) time.Duration {
+ dur, err := time.ParseDuration(timeout)
+ if err != nil {
+ return 15 * time.Second
+ }
+ return dur
+}
+
+// captureConsoleLog captures a console log event and stores it
+func (b *BrowseTools) captureConsoleLog(e *runtime.EventConsoleAPICalled) {
+ // Add to logs with mutex protection
+ b.consoleLogsMutex.Lock()
+ defer b.consoleLogsMutex.Unlock()
+
+ // Add the log and maintain max size
+ b.consoleLogs = append(b.consoleLogs, e)
+ if len(b.consoleLogs) > b.maxConsoleLogs {
+ b.consoleLogs = b.consoleLogs[len(b.consoleLogs)-b.maxConsoleLogs:]
+ }
+}
+
+// RecentConsoleLogsTool definition
+type recentConsoleLogsInput struct {
+ Limit int `json:"limit,omitempty"`
+}
+
+// NewRecentConsoleLogsTool creates a tool for retrieving recent console logs
+func (b *BrowseTools) NewRecentConsoleLogsTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_recent_console_logs",
+ Description: "Get recent browser console logs",
+ InputSchema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of log entries to return (default: 100)"
+ }
+ }
+ }`),
+ Run: b.recentConsoleLogsRun,
+ }
+}
+
+func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input recentConsoleLogsInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ // Ensure browser is initialized
+ _, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Apply limit (default to 100 if not specified)
+ limit := 100
+ if input.Limit > 0 {
+ limit = input.Limit
+ }
+
+ // Get console logs with mutex protection
+ b.consoleLogsMutex.Lock()
+ logs := make([]*runtime.EventConsoleAPICalled, 0, len(b.consoleLogs))
+ start := 0
+ if len(b.consoleLogs) > limit {
+ start = len(b.consoleLogs) - limit
+ }
+ logs = append(logs, b.consoleLogs[start:]...)
+ b.consoleLogsMutex.Unlock()
+
+ // Format the logs as JSON
+ logData, err := json.MarshalIndent(logs, "", " ")
+ if err != nil {
+ return llm.ErrorfToolOut("failed to serialize logs: %w", err)
+ }
+
+ // Format the logs
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Retrieved %d console log entries:\n\n", len(logs)))
+
+ if len(logs) == 0 {
+ sb.WriteString("No console logs captured.")
+ } else {
+ // Add the JSON data for full details
+ sb.WriteString(string(logData))
+ }
+
+ return llm.ToolOut{LLMContent: llm.TextContent(sb.String())}
+}
+
+// ClearConsoleLogsTool definition
+type clearConsoleLogsInput struct{}
+
+// NewClearConsoleLogsTool creates a tool for clearing console logs
+func (b *BrowseTools) NewClearConsoleLogsTool() *llm.Tool {
+ return &llm.Tool{
+ Name: "browser_clear_console_logs",
+ Description: "Clear all captured browser console logs",
+ InputSchema: llm.EmptySchema(),
+ Run: b.clearConsoleLogsRun,
+ }
+}
+
+func (b *BrowseTools) clearConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input clearConsoleLogsInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorfToolOut("invalid input: %w", err)
+ }
+
+ // Ensure browser is initialized
+ _, err := b.GetBrowserContext()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+
+ // Clear console logs with mutex protection
+ b.consoleLogsMutex.Lock()
+ logCount := len(b.consoleLogs)
+ b.consoleLogs = make([]*runtime.EventConsoleAPICalled, 0)
+ b.consoleLogsMutex.Unlock()
+
+ return llm.ToolOut{LLMContent: llm.TextContent(fmt.Sprintf("Cleared %d console log entries.", logCount))}
+}
@@ -0,0 +1,408 @@
+package browse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+ "shelley.exe.dev/llm"
+)
+
+func TestToolCreation(t *testing.T) {
+ // Create browser tools instance
+ tools := NewBrowseTools(context.Background())
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Test each tool has correct name and description
+ toolTests := []struct {
+ tool *llm.Tool
+ expectedName string
+ shortDesc string
+ requiredProps []string
+ }{
+ {tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}},
+ {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
+ {tools.NewResizeTool(), "browser_resize", "Resize", []string{"width", "height"}},
+ {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
+ }
+
+ for _, tt := range toolTests {
+ t.Run(tt.expectedName, func(t *testing.T) {
+ if tt.tool.Name != tt.expectedName {
+ t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
+ }
+
+ if !strings.Contains(tt.tool.Description, tt.shortDesc) {
+ t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
+ }
+
+ // Verify schema has required properties
+ if len(tt.requiredProps) > 0 {
+ var schema struct {
+ Required []string `json:"required"`
+ }
+ if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
+ t.Fatalf("failed to unmarshal schema: %v", err)
+ }
+
+ for _, prop := range tt.requiredProps {
+ if !slices.Contains(schema.Required, prop) {
+ t.Errorf("property %q should be required", prop)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestGetTools(t *testing.T) {
+ // Create browser tools instance
+ tools := NewBrowseTools(context.Background())
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Test with screenshot tools included
+ t.Run("with screenshots", func(t *testing.T) {
+ toolsWithScreenshots := tools.GetTools(true)
+ if len(toolsWithScreenshots) != 7 {
+ t.Errorf("expected 7 tools with screenshots, got %d", len(toolsWithScreenshots))
+ }
+
+ // Check tool naming convention
+ for _, tool := range toolsWithScreenshots {
+ // Most tools have browser_ prefix, except for read_image
+ if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") {
+ t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
+ }
+ }
+ })
+
+ // Test without screenshot tools
+ t.Run("without screenshots", func(t *testing.T) {
+ noScreenshotTools := tools.GetTools(false)
+ if len(noScreenshotTools) != 5 {
+ t.Errorf("expected 5 tools without screenshots, got %d", len(noScreenshotTools))
+ }
+ })
+}
+
+// TestBrowserInitialization verifies that the browser can start correctly
+func TestBrowserInitialization(t *testing.T) {
+ // Skip long tests in short mode
+ if testing.Short() {
+ t.Skip("skipping browser initialization test in short mode")
+ }
+
+ // Create browser tools instance
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ tools := NewBrowseTools(ctx)
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Get browser context (this initializes the browser)
+ browserCtx, err := tools.GetBrowserContext()
+ if err != nil {
+ if strings.Contains(err.Error(), "failed to start browser") {
+ t.Skip("Browser automation not available in this environment")
+ }
+ t.Fatalf("Failed to get browser context: %v", err)
+ }
+
+ // Try to navigate to a simple page
+ var title string
+ err = chromedp.Run(browserCtx,
+ chromedp.Navigate("about:blank"),
+ chromedp.Title(&title),
+ )
+ if err != nil {
+ t.Fatalf("Failed to navigate to about:blank: %v", err)
+ }
+
+ t.Logf("Successfully navigated to about:blank, title: %q", title)
+}
+
+// TestNavigateTool verifies that the navigate tool works correctly
+func TestNavigateTool(t *testing.T) {
+ // Skip long tests in short mode
+ if testing.Short() {
+ t.Skip("skipping navigate tool test in short mode")
+ }
+
+ // Create browser tools instance
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ tools := NewBrowseTools(ctx)
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Get the navigate tool
+ navTool := tools.NewNavigateTool()
+
+ // Create input for the navigate tool
+ input := map[string]string{"url": "https://example.com"}
+ inputJSON, _ := json.Marshal(input)
+
+ // Call the tool
+ toolOut := navTool.Run(ctx, json.RawMessage(inputJSON))
+ if toolOut.Error != nil {
+ t.Fatalf("Error running navigate tool: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // Verify the response is successful
+ resultText := result[0].Text
+ if !strings.Contains(resultText, "done") {
+ // If browser automation is not available, skip the test
+ if strings.Contains(resultText, "browser automation not available") {
+ t.Skip("Browser automation not available in this environment")
+ } else {
+ t.Fatalf("Expected done in result text, got: %s", resultText)
+ }
+ }
+
+ // Try to get the page title to verify the navigation worked
+ browserCtx, err := tools.GetBrowserContext()
+ if err != nil {
+ // If browser automation is not available, skip the test
+ if strings.Contains(err.Error(), "browser automation not available") {
+ t.Skip("Browser automation not available in this environment")
+ } else {
+ t.Fatalf("Failed to get browser context: %v", err)
+ }
+ }
+
+ var title string
+ err = chromedp.Run(browserCtx, chromedp.Title(&title))
+ if err != nil {
+ t.Fatalf("Failed to get page title: %v", err)
+ }
+
+ t.Logf("Successfully navigated to example.com, title: %q", title)
+ if title != "Example Domain" {
+ t.Errorf("Expected title 'Example Domain', got '%s'", title)
+ }
+}
+
+// TestScreenshotTool tests that the screenshot tool properly saves files
+func TestScreenshotTool(t *testing.T) {
+ // Create browser tools instance
+ ctx := context.Background()
+ tools := NewBrowseTools(ctx)
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Test SaveScreenshot function directly
+ testData := []byte("test image data")
+ id := tools.SaveScreenshot(testData)
+ if id == "" {
+ t.Fatal("SaveScreenshot returned empty ID")
+ }
+
+ // Get the file path and check if the file exists
+ filePath := GetScreenshotPath(id)
+ _, err := os.Stat(filePath)
+ if err != nil {
+ t.Fatalf("Failed to find screenshot file: %v", err)
+ }
+
+ // Read the file contents
+ contents, err := os.ReadFile(filePath)
+ if err != nil {
+ t.Fatalf("Failed to read screenshot file: %v", err)
+ }
+
+ // Check the file contents
+ if string(contents) != string(testData) {
+ t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
+ }
+
+ // Clean up the test file
+ os.Remove(filePath)
+}
+
+func TestReadImageTool(t *testing.T) {
+ // Create a test BrowseTools instance
+ ctx := context.Background()
+ browseTools := NewBrowseTools(ctx)
+ t.Cleanup(func() {
+ browseTools.Close()
+ })
+
+ // Create a test image
+ testDir := t.TempDir()
+ testImagePath := filepath.Join(testDir, "test_image.png")
+
+ // Create a small 1x1 black PNG image
+ smallPng := []byte{
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
+ 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
+ 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+ 0x42, 0x60, 0x82,
+ }
+
+ // Write the test image
+ err := os.WriteFile(testImagePath, smallPng, 0o644)
+ if err != nil {
+ t.Fatalf("Failed to create test image: %v", err)
+ }
+
+ // Create the tool
+ readImageTool := browseTools.NewReadImageTool()
+
+ // Prepare input
+ input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
+
+ // Run the tool
+ toolOut := readImageTool.Run(ctx, json.RawMessage(input))
+ if toolOut.Error != nil {
+ t.Fatalf("Read image tool failed: %v", toolOut.Error)
+ }
+ result := toolOut.LLMContent
+
+ // In the updated code, result is already a []llm.Content
+ contents := result
+
+ // Check that we got at least two content objects
+ if len(contents) < 2 {
+ t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
+ }
+
+ // Check that the second content has image data
+ if contents[1].MediaType == "" {
+ t.Errorf("Expected MediaType in second content")
+ }
+
+ if contents[1].Data == "" {
+ t.Errorf("Expected Data in second content")
+ }
+}
+
+// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
+func TestDefaultViewportSize(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ // Skip if CI or headless testing environment
+ if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
+ t.Skip("Skipping browser test in CI/headless environment")
+ }
+
+ tools := NewBrowseTools(ctx)
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // Navigate to a simple page to ensure the browser is ready
+ navInput := json.RawMessage(`{"url": "about:blank"}`)
+ toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+ if toolOut.Error != nil {
+ if strings.Contains(toolOut.Error.Error(), "browser automation not available") {
+ t.Skip("Browser automation not available in this environment")
+ }
+ t.Fatalf("Navigation error: %v", toolOut.Error)
+ }
+ content := toolOut.LLMContent
+ if !strings.Contains(content[0].Text, "done") {
+ t.Fatalf("Expected done in navigation response, got: %s", content[0].Text)
+ }
+
+ // Check default viewport dimensions via JavaScript
+ evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
+ toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+ if toolOut.Error != nil {
+ t.Fatalf("Evaluation error: %v", toolOut.Error)
+ }
+ content = toolOut.LLMContent
+
+ // Parse the result to verify dimensions
+ var response struct {
+ Width float64 `json:"width"`
+ Height float64 `json:"height"`
+ }
+
+ text := content[0].Text
+ text = strings.TrimPrefix(text, "<javascript_result>")
+ text = strings.TrimSuffix(text, "</javascript_result>")
+
+ if err := json.Unmarshal([]byte(text), &response); err != nil {
+ t.Fatalf("Failed to parse evaluation response (%q => %q): %v", content[0].Text, text, err)
+ }
+
+ // Verify the default viewport size is 1280x720
+ expectedWidth := 1280.0
+ expectedHeight := 720.0
+
+ if response.Width != expectedWidth {
+ t.Errorf("Expected default width %v, got %v", expectedWidth, response.Width)
+ }
+ if response.Height != expectedHeight {
+ t.Errorf("Expected default height %v, got %v", expectedHeight, response.Height)
+ }
+}
+
+// TestBrowserIdleShutdownAndRestart verifies the browser shuts down after idle and can restart
+func TestBrowserIdleShutdownAndRestart(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ // Use a short idle timeout for testing
+ idleTimeout := 100 * time.Millisecond
+ tools := NewBrowseToolsWithIdleTimeout(ctx, idleTimeout)
+ t.Cleanup(func() {
+ tools.Close()
+ })
+
+ // First use - should start the browser
+ browserCtx1, err := tools.GetBrowserContext()
+ if err != nil {
+ if strings.Contains(err.Error(), "failed to start browser") {
+ t.Skip("Browser automation not available in this environment")
+ }
+ t.Fatalf("Failed to get browser context: %v", err)
+ }
+ if browserCtx1 == nil {
+ t.Fatal("Expected non-nil browser context")
+ }
+
+ // Wait for idle timeout to fire
+ time.Sleep(idleTimeout + 50*time.Millisecond)
+
+ // Second use - should start a new browser (old one was killed)
+ browserCtx2, err := tools.GetBrowserContext()
+ if err != nil {
+ t.Fatalf("Failed to get browser context after idle: %v", err)
+ }
+ if browserCtx2 == nil {
+ t.Fatal("Expected non-nil browser context after restart")
+ }
+
+ // The contexts should be different (new browser instance)
+ if browserCtx1 == browserCtx2 {
+ t.Error("Expected different browser context after idle shutdown")
+ }
+
+ // Verify the new browser actually works
+ navTool := tools.NewNavigateTool()
+ input := json.RawMessage(`{"url": "about:blank"}`)
+ toolOut := navTool.Run(ctx, input)
+ if toolOut.Error != nil {
+ t.Fatalf("Navigate failed after restart: %v", toolOut.Error)
+ }
+}
@@ -0,0 +1,2 @@
+// Package browse contains browser automation tools
+package browse
@@ -0,0 +1,21 @@
+package browse
+
+import (
+ "context"
+
+ "shelley.exe.dev/llm"
+)
+
+// RegisterBrowserTools returns all browser tools ready to be added to an agent.
+// It also returns a cleanup function that should be called when done to properly close the browser.
+// The browser will be initialized lazily when a browser tool is first used.
+func RegisterBrowserTools(ctx context.Context, supportsScreenshots bool) ([]*llm.Tool, func()) {
+ browserTools := NewBrowseTools(ctx)
+
+ return browserTools.GetTools(supportsScreenshots), func() {
+ browserTools.Close()
+ }
+}
+
+// Tool is an alias for llm.Tool to make the documentation clearer
+type Tool = llm.Tool
@@ -0,0 +1,102 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "shelley.exe.dev/llm"
+)
+
+// ChangeDirTool changes the working directory for bash commands.
+type ChangeDirTool struct {
+ // WorkingDir is the shared mutable working directory.
+ WorkingDir *MutableWorkingDir
+ // OnChange is called after the working directory changes successfully.
+ // This can be used to persist the change to a database.
+ OnChange func(newDir string)
+}
+
+const (
+ changeDirName = "change_dir"
+ changeDirDescription = `Change the working directory for subsequent bash commands.
+
+This affects the working directory used by the bash tool. The directory must exist.
+Relative paths are resolved against the current working directory.
+
+Use this to navigate the filesystem persistently across bash commands,
+rather than using 'cd' within each bash command (which doesn't persist).
+`
+ changeDirInputSchema = `{
+ "type": "object",
+ "required": ["path"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The directory path to change to (absolute or relative)"
+ }
+ }
+}`
+)
+
+type changeDirInput struct {
+ Path string `json:"path"`
+}
+
+// Tool returns an llm.Tool for changing directories.
+func (c *ChangeDirTool) Tool() *llm.Tool {
+ return &llm.Tool{
+ Name: changeDirName,
+ Description: changeDirDescription,
+ InputSchema: llm.MustSchema(changeDirInputSchema),
+ Run: c.Run,
+ }
+}
+
+// Run executes the change_dir tool.
+func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var req changeDirInput
+ if err := json.Unmarshal(m, &req); err != nil {
+ return llm.ErrorfToolOut("failed to parse change_dir input: %w", err)
+ }
+
+ if req.Path == "" {
+ return llm.ErrorfToolOut("path is required")
+ }
+
+ // Get current working directory
+ currentWD := c.WorkingDir.Get()
+
+ // Resolve the path
+ targetPath := req.Path
+ if !filepath.IsAbs(targetPath) {
+ targetPath = filepath.Join(currentWD, targetPath)
+ }
+ targetPath = filepath.Clean(targetPath)
+
+ // Validate the directory exists
+ info, err := os.Stat(targetPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return llm.ErrorfToolOut("directory does not exist: %s", targetPath)
+ }
+ return llm.ErrorfToolOut("failed to stat path: %w", err)
+ }
+ if !info.IsDir() {
+ return llm.ErrorfToolOut("path is not a directory: %s", targetPath)
+ }
+
+ // Update the working directory
+ c.WorkingDir.Set(targetPath)
+
+ // Notify callback if set
+ if c.OnChange != nil {
+ c.OnChange(targetPath)
+ }
+
+ return llm.ToolOut{
+ LLMContent: llm.TextContent(fmt.Sprintf("Changed working directory to: %s", targetPath)),
+ }
+}
@@ -0,0 +1,215 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestChangeDirTool(t *testing.T) {
+ // Create a temp directory structure
+ tmpDir := t.TempDir()
+ subDir := filepath.Join(tmpDir, "subdir")
+ if err := os.Mkdir(subDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ wd := NewMutableWorkingDir(tmpDir)
+ tool := &ChangeDirTool{WorkingDir: wd}
+
+ t.Run("change to absolute path", func(t *testing.T) {
+ // Reset
+ wd.Set(tmpDir)
+
+ input, _ := json.Marshal(changeDirInput{Path: subDir})
+ result := tool.Run(context.Background(), input)
+
+ if result.Error != nil {
+ t.Fatalf("unexpected error: %v", result.Error)
+ }
+
+ if wd.Get() != subDir {
+ t.Errorf("expected working dir %q, got %q", subDir, wd.Get())
+ }
+ })
+
+ t.Run("change to relative path", func(t *testing.T) {
+ // Reset
+ wd.Set(tmpDir)
+
+ input, _ := json.Marshal(changeDirInput{Path: "subdir"})
+ result := tool.Run(context.Background(), input)
+
+ if result.Error != nil {
+ t.Fatalf("unexpected error: %v", result.Error)
+ }
+
+ if wd.Get() != subDir {
+ t.Errorf("expected working dir %q, got %q", subDir, wd.Get())
+ }
+ })
+
+ t.Run("change to parent directory", func(t *testing.T) {
+ wd.Set(subDir)
+
+ input, _ := json.Marshal(changeDirInput{Path: ".."})
+ result := tool.Run(context.Background(), input)
+
+ if result.Error != nil {
+ t.Fatalf("unexpected error: %v", result.Error)
+ }
+
+ if wd.Get() != tmpDir {
+ t.Errorf("expected working dir %q, got %q", tmpDir, wd.Get())
+ }
+ })
+
+ t.Run("error on non-existent path", func(t *testing.T) {
+ wd.Set(tmpDir)
+
+ input, _ := json.Marshal(changeDirInput{Path: "/nonexistent/path"})
+ result := tool.Run(context.Background(), input)
+
+ if result.Error == nil {
+ t.Fatal("expected error for non-existent path")
+ }
+ })
+
+ t.Run("error on file path", func(t *testing.T) {
+ // Create a file
+ filePath := filepath.Join(tmpDir, "file.txt")
+ if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ wd.Set(tmpDir)
+
+ input, _ := json.Marshal(changeDirInput{Path: filePath})
+ result := tool.Run(context.Background(), input)
+
+ if result.Error == nil {
+ t.Fatal("expected error for file path")
+ }
+ })
+
+ t.Run("OnChange callback is called", func(t *testing.T) {
+ wd.Set(tmpDir)
+
+ var callbackDir string
+ toolWithCallback := &ChangeDirTool{
+ WorkingDir: wd,
+ OnChange: func(newDir string) {
+ callbackDir = newDir
+ },
+ }
+
+ input, _ := json.Marshal(changeDirInput{Path: subDir})
+ result := toolWithCallback.Run(context.Background(), input)
+
+ if result.Error != nil {
+ t.Fatalf("unexpected error: %v", result.Error)
+ }
+
+ if callbackDir != subDir {
+ t.Errorf("expected callback dir %q, got %q", subDir, callbackDir)
+ }
+ })
+}
+
+func TestChangeDirWithBash(t *testing.T) {
+ // Create a temp directory structure
+ tmpDir := t.TempDir()
+ subDir := filepath.Join(tmpDir, "subdir")
+ if err := os.Mkdir(subDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a file in subdir
+ testFile := filepath.Join(subDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ wd := NewMutableWorkingDir(tmpDir)
+ changeDirTool := &ChangeDirTool{WorkingDir: wd}
+ bashTool := &BashTool{WorkingDir: wd}
+
+ ctx := context.Background()
+
+ // Run pwd to verify starting directory
+ input, _ := json.Marshal(bashInput{Command: "pwd"})
+ result := bashTool.Run(ctx, input)
+ if result.Error != nil {
+ t.Fatalf("bash pwd failed: %v", result.Error)
+ }
+
+ // Change directory
+ cdInput, _ := json.Marshal(changeDirInput{Path: subDir})
+ result = changeDirTool.Run(ctx, cdInput)
+ if result.Error != nil {
+ t.Fatalf("change_dir failed: %v", result.Error)
+ }
+
+ // Run ls - should now see test.txt
+ result = bashTool.Run(ctx, json.RawMessage(`{"command": "ls"}`))
+ if result.Error != nil {
+ t.Fatalf("bash ls failed: %v", result.Error)
+ }
+
+ // Verify we can see test.txt (indicating we're in subdir)
+ if len(result.LLMContent) == 0 {
+ t.Fatal("expected output from ls")
+ }
+ output := result.LLMContent[0].Text
+ if output == "" {
+ t.Fatal("expected non-empty output from ls")
+ }
+ // The output should contain "test.txt"
+ if !contains(output, "test.txt") {
+ t.Errorf("expected ls output to contain 'test.txt', got: %q", output)
+ }
+}
+
+func contains(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+func TestBashToolMissingWorkingDir(t *testing.T) {
+ // Create a temp directory, then remove it
+ tmpDir := t.TempDir()
+ subDir := filepath.Join(tmpDir, "subdir")
+ if err := os.Mkdir(subDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ wd := NewMutableWorkingDir(subDir)
+ bashTool := &BashTool{WorkingDir: wd}
+
+ // Remove the directory
+ if err := os.RemoveAll(subDir); err != nil {
+ t.Fatal(err)
+ }
+
+ // Try to run a command - should get a clear error
+ input, _ := json.Marshal(bashInput{Command: "ls"})
+ result := bashTool.Run(context.Background(), input)
+
+ if result.Error == nil {
+ t.Fatal("expected error when working directory doesn't exist")
+ }
+
+ errStr := result.Error.Error()
+ if !contains(errStr, "working directory does not exist") {
+ t.Errorf("expected error about missing working directory, got: %s", errStr)
+ }
+ if !contains(errStr, "change_dir") {
+ t.Errorf("expected error to mention change_dir tool, got: %s", errStr)
+ }
+}
@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,92 @@
+// Modified from rsc.io/edit
+
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package edit implements buffered position-based editing of byte slices.
+package editbuf
+
+import (
+ "fmt"
+ "sort"
+)
+
+// A Buffer is a queue of edits to apply to a given byte slice.
+type Buffer struct {
+ old []byte
+ q edits
+}
+
+// An edit records a single text modification: change the bytes in [start,end) to new.
+type edit struct {
+ start int
+ end int
+ new string
+}
+
+// An edits is a list of edits that is sortable by start offset, breaking ties by end offset.
+type edits []edit
+
+func (x edits) Len() int { return len(x) }
+func (x edits) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
+func (x edits) Less(i, j int) bool {
+ if x[i].start != x[j].start {
+ return x[i].start < x[j].start
+ }
+ return x[i].end < x[j].end
+}
+
+// NewBuffer returns a new buffer to accumulate changes to an initial data slice.
+// The returned buffer maintains a reference to the data, so the caller must ensure
+// the data is not modified until after the Buffer is done being used.
+func NewBuffer(old []byte) *Buffer {
+ return &Buffer{old: old}
+}
+
+// Insert inserts the new string at old[pos:pos].
+func (b *Buffer) Insert(pos int, new string) {
+ if pos < 0 || pos > len(b.old) {
+ panic("invalid edit position")
+ }
+ b.q = append(b.q, edit{pos, pos, new})
+}
+
+// Delete deletes the text old[start:end].
+func (b *Buffer) Delete(start, end int) {
+ if end < start || start < 0 || end > len(b.old) {
+ panic("invalid edit position")
+ }
+ b.q = append(b.q, edit{start, end, ""})
+}
+
+// Replace replaces old[start:end] with new.
+func (b *Buffer) Replace(start, end int, new string) {
+ if end < start || start < 0 || end > len(b.old) {
+ panic("invalid edit position")
+ }
+ b.q = append(b.q, edit{start, end, new})
+}
+
+// Bytes returns a new byte slice containing the original data
+// with the queued edits applied.
+func (b *Buffer) Bytes() ([]byte, error) {
+ // Sort edits by starting position and then by ending position.
+ // Breaking ties by ending position allows insertions at point x
+ // to be applied before a replacement of the text at [x, y).
+ sort.Stable(b.q)
+
+ var new []byte
+ offset := 0
+ for i, e := range b.q {
+ if e.start < offset {
+ e0 := b.q[i-1]
+ return nil, fmt.Errorf("overlapping edits: [%d,%d)->%q, [%d,%d)->%q", e0.start, e0.end, e0.new, e.start, e.end, e.new)
+ }
+ new = append(new, b.old[offset:e.start]...)
+ offset = e.end
+ new = append(new, e.new...)
+ }
+ new = append(new, b.old[offset:]...)
+ return new, nil
+}
@@ -0,0 +1,236 @@
+package claudetool
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os/exec"
+ "strings"
+
+ "shelley.exe.dev/llm"
+)
+
+// LLMServiceProvider defines the interface for getting LLM services
+type LLMServiceProvider interface {
+ GetService(modelID string) (llm.Service, error)
+ GetAvailableModels() []string
+}
+
+// KeywordTool provides keyword search functionality
+type KeywordTool struct {
+ llmProvider LLMServiceProvider
+ workingDir *MutableWorkingDir
+}
+
+// NewKeywordTool creates a new keyword tool with the given LLM provider
+func NewKeywordTool(provider LLMServiceProvider) *KeywordTool {
+ return &KeywordTool{llmProvider: provider}
+}
+
+// NewKeywordToolWithWorkingDir creates a new keyword tool with the given LLM provider and shared working directory
+func NewKeywordToolWithWorkingDir(provider LLMServiceProvider, wd *MutableWorkingDir) *KeywordTool {
+ return &KeywordTool{llmProvider: provider, workingDir: wd}
+}
+
+// Tool returns the LLM tool definition
+func (k *KeywordTool) Tool() *llm.Tool {
+ return &llm.Tool{
+ Name: keywordName,
+ Description: keywordDescription,
+ InputSchema: llm.MustSchema(keywordInputSchema),
+ Run: k.keywordRun,
+ }
+}
+
+const (
+ keywordName = "keyword_search"
+ keywordDescription = `
+keyword_search locates files with a search-and-filter approach.
+Use when navigating unfamiliar codebases with only conceptual understanding or vague user questions.
+
+Effective use:
+- Provide a detailed query for accurate relevance ranking
+- Prefer MANY SPECIFIC terms over FEW GENERAL ones (high precision beats high recall)
+- Order search terms by importance (most important first)
+- Supports regex search terms for flexible matching
+
+IMPORTANT: Do NOT use this tool if you have precise information like log lines, error messages, stack traces, filenames, or symbols. Use direct approaches (rg, cat, etc.) instead.
+`
+
+ // If you modify this, update the termui template for prettier rendering.
+ keywordInputSchema = `
+{
+ "type": "object",
+ "required": [
+ "query",
+ "search_terms"
+ ],
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "A detailed statement of what you're trying to find or learn."
+ },
+ "search_terms": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of search terms in descending order of importance."
+ }
+ }
+}
+`
+)
+
+type keywordInput struct {
+ Query string `json:"query"`
+ SearchTerms []string `json:"search_terms"`
+}
+
+//go:embed keyword_system_prompt.txt
+var keywordSystemPrompt string
+
+// FindRepoRoot attempts to find the git repository root from the current directory
+func FindRepoRoot(wd string) (string, error) {
+ cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+ cmd.Dir = wd
+ out, err := cmd.Output()
+ // todo: cwd here and throughout
+ if err != nil {
+ return "", fmt.Errorf("failed to find git repository root: %w", err)
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// keywordRun is the main implementation using the LLM provider
+func (k *KeywordTool) keywordRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var input keywordInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ wd := k.workingDir.Get()
+ root, err := FindRepoRoot(wd)
+ if err == nil {
+ wd = root
+ }
+ slog.InfoContext(ctx, "keyword search input", "query", input.Query, "keywords", input.SearchTerms, "wd", wd)
+
+ // first remove stopwords
+ var keep []string
+ for _, term := range input.SearchTerms {
+ out, err := ripgrep(ctx, wd, []string{term})
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ if len(out) > 64*1024 {
+ slog.InfoContext(ctx, "keyword search result too large", "term", term, "bytes", len(out))
+ continue
+ }
+ keep = append(keep, term)
+ }
+
+ if len(keep) == 0 {
+ return llm.ToolOut{LLMContent: llm.TextContent("each of those search terms yielded too many results")}
+ }
+
+ // peel off keywords until we get a result that fits in the query window
+ var out string
+ for {
+ var err error
+ out, err = ripgrep(ctx, wd, keep)
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ if len(out) < 128*1024 {
+ break
+ }
+ keep = keep[:len(keep)-1]
+ }
+
+ // Select the best available LLM service
+ llmService, err := k.selectBestLLM(k.llmProvider)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to get LLM service: %w", err)
+ }
+
+ // Create the filtering request
+ system := []llm.SystemContent{
+ {Type: "text", Text: strings.TrimSpace(keywordSystemPrompt)},
+ }
+
+ initialMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ llm.StringContent("<pwd>\n" + wd + "\n</pwd>"),
+ llm.StringContent("<ripgrep_results>\n" + out + "\n</ripgrep_results>"),
+ llm.StringContent("<query>\n" + input.Query + "\n</query>"),
+ },
+ }
+
+ req := &llm.Request{
+ Messages: []llm.Message{initialMessage},
+ System: system,
+ }
+
+ resp, err := llmService.Do(ctx, req)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to send relevance filtering message: %w", err)
+ }
+ if len(resp.Content) != 1 {
+ return llm.ErrorfToolOut("unexpected number of messages (%d) in relevance filtering response: %v", len(resp.Content), resp.Content)
+ }
+
+ filtered := resp.Content[0].Text
+
+ slog.InfoContext(ctx, "keyword search results processed",
+ "bytes", len(out),
+ "lines", strings.Count(out, "\n"),
+ "files", strings.Count(out, "\n\n"),
+ "query", input.Query,
+ "filtered", filtered,
+ )
+
+ return llm.ToolOut{LLMContent: llm.TextContent(resp.Content[0].Text)}
+}
+
+func ripgrep(ctx context.Context, wd string, terms []string) (string, error) {
+ args := []string{"-C", "10", "-i", "--line-number", "--with-filename"}
+ for _, term := range terms {
+ args = append(args, "-e", term)
+ }
+ cmd := exec.CommandContext(ctx, "rg", args...)
+ cmd.Dir = wd
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ // ripgrep returns exit code 1 when no matches are found, which is not an error for us
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+ return "no matches found", nil
+ }
+ return "", fmt.Errorf("search failed: %v\n%s", err, out)
+ }
+ outStr := string(out)
+ return outStr, nil
+}
+
+// selectBestLLM selects the best available LLM service for keyword search
+func (k *KeywordTool) selectBestLLM(provider LLMServiceProvider) (llm.Service, error) {
+ // Preferred models in order of preference for keyword search (fast, cheap models preferred)
+ preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
+
+ for _, model := range preferredModels {
+ svc, err := provider.GetService(model)
+ if err == nil {
+ return svc, nil
+ }
+ }
+
+ // If no preferred model is available, try any available model
+ available := provider.GetAvailableModels()
+ if len(available) > 0 {
+ return provider.GetService(available[0])
+ }
+
+ return nil, fmt.Errorf("no LLM services available")
+}
@@ -0,0 +1,28 @@
+You are a code search relevance evaluator. Your task is to analyze ripgrep results and determine which files are most relevant to the user's query.
+
+INPUT FORMAT:
+- You will receive ripgrep output containing file matches for keywords with 10 lines of context
+- At the end will be "QUERY: <original search query>"
+
+ANALYSIS INSTRUCTIONS:
+1. Examine each file match and its surrounding context
+2. Evaluate relevance to the query based on:
+ - Direct relevance to concepts in the query
+ - Implementation of functionality described in the query
+ - Evidence of patterns or systems related to the query
+3. Exercise strict judgment - only return files that are genuinely relevant
+
+OUTPUT FORMAT:
+Respond with a plain text list of the most relevant files in decreasing order of relevance:
+
+/path/to/most/relevant/file: Concise relevance explanation
+/path/to/second/file: Concise relevance explanation
+...
+
+IMPORTANT:
+- Only include files with meaningful relevance to the query
+- Keep it short, don't blather
+- Do NOT list all files that had keyword matches
+- Focus on quality over quantity
+- If no files are truly relevant, return "No relevant files found"
+- Use absolute file paths
@@ -0,0 +1,228 @@
+// Package onstart provides codebase analysis used to inform the initial system prompt.
+package onstart
+
+import (
+ "bufio"
+ "bytes"
+ "cmp"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "golang.org/x/sync/errgroup"
+)
+
+// Codebase contains metadata about the codebase.
+type Codebase struct {
+ // ExtensionCounts tracks the number of files with each extension
+ ExtensionCounts map[string]int
+ // Total number of files analyzed
+ TotalFiles int
+ // BuildFiles contains paths to build and configuration files
+ BuildFiles []string
+ // DocumentationFiles contains paths to documentation files
+ DocumentationFiles []string
+ // GuidanceFiles contains paths to files that provide context and guidance to LLMs
+ GuidanceFiles []string
+ // InjectFiles contains paths to critical guidance files (like DEAR_LLM.md, claude.md, and cursorrules)
+ // that need to be injected into the system prompt for highest visibility
+ InjectFiles []string
+ // InjectFileContents maps paths to file contents for critical inject files
+ // to avoid requiring an extra file read during template rendering
+ InjectFileContents map[string]string
+}
+
+// AnalyzeCodebase walks the codebase and analyzes the paths it finds.
+func AnalyzeCodebase(ctx context.Context, repoPath string) (*Codebase, error) {
+ // TODO: do a filesystem walk instead?
+ // There's a balance: git ls-files skips node_modules etc,
+ // but some guidance files might be locally .gitignored.
+ cmd := exec.Command("git", "ls-files", "-z")
+ cmd.Dir = repoPath
+
+ r, w := io.Pipe() // stream and scan rather than buffer
+ cmd.Stdout = w
+
+ err := cmd.Start()
+ if err != nil {
+ return nil, err
+ }
+
+ extCounts := make(map[string]int)
+ var buildFiles []string
+ var documentationFiles []string
+ var guidanceFiles []string
+ var injectFiles []string
+ injectFileContents := make(map[string]string)
+ var totalFiles int
+
+ eg, _ := errgroup.WithContext(ctx)
+
+ eg.Go(func() error {
+ defer r.Close()
+
+ scanner := bufio.NewScanner(r)
+ scanner.Split(scanZero)
+ for scanner.Scan() {
+ file := scanner.Text()
+ file = strings.TrimSpace(file)
+ if file == "" {
+ continue
+ }
+ totalFiles++
+ ext := strings.ToLower(filepath.Ext(file))
+ ext = cmp.Or(ext, "<no-extension>")
+ extCounts[ext]++
+
+ fileCategory := categorizeFile(file)
+ // fmt.Println(file, "->", fileCategory)
+ switch fileCategory {
+ case "build":
+ buildFiles = append(buildFiles, file)
+ case "documentation":
+ documentationFiles = append(documentationFiles, file)
+ case "guidance":
+ guidanceFiles = append(guidanceFiles, file)
+ case "inject":
+ injectFiles = append(injectFiles, file)
+ }
+ }
+ return scanner.Err()
+ })
+
+ // Wait for the command to complete
+ eg.Go(func() error {
+ err := cmd.Wait()
+ if err != nil {
+ w.CloseWithError(err)
+ } else {
+ w.Close()
+ }
+ return err
+ })
+
+ if err := eg.Wait(); err != nil {
+ return nil, err
+ }
+
+ // Read content of inject files
+ for _, filePath := range injectFiles {
+ absPath := filepath.Join(repoPath, filePath)
+ content, err := os.ReadFile(absPath)
+ if err != nil {
+ fmt.Printf("Warning: Failed to read inject file %s: %v\n", filePath, err)
+ continue
+ }
+ injectFileContents[filePath] = string(content)
+ }
+
+ return &Codebase{
+ ExtensionCounts: extCounts,
+ TotalFiles: totalFiles,
+ BuildFiles: buildFiles,
+ DocumentationFiles: documentationFiles,
+ GuidanceFiles: guidanceFiles,
+ InjectFiles: injectFiles,
+ InjectFileContents: injectFileContents,
+ }, nil
+}
+
+// categorizeFile categorizes a file into one of four categories: build, documentation, guidance, or inject.
+// Returns an empty string if the file doesn't belong to any of these categories.
+// categorizeFile categorizes a file into one of four categories: build, documentation, guidance, or inject.
+// Returns an empty string if the file doesn't belong to any of these categories.
+// The path parameter is relative to the repository root as returned by git ls-files.
+func categorizeFile(path string) string {
+ filename := filepath.Base(path)
+ lowerPath := strings.ToLower(path)
+ lowerFilename := strings.ToLower(filename)
+
+ // InjectFiles - critical guidance files that should be injected into the system prompt
+ // These are repository root files only - files directly in the repo root, not in subdirectories
+ // Since git ls-files returns paths relative to repo root, we just need to check for absence of path separators
+ isRepoRootFile := !strings.Contains(path, "/")
+ if isRepoRootFile {
+ if (strings.HasPrefix(lowerFilename, "claude.") && strings.HasSuffix(lowerFilename, ".md")) ||
+ strings.HasPrefix(lowerFilename, "dear_llm") ||
+ (strings.HasPrefix(lowerFilename, "agents.") && strings.HasSuffix(lowerFilename, ".md")) ||
+ strings.Contains(lowerFilename, "cursorrules") {
+ return "inject"
+ }
+ }
+
+ // GitHub Copilot: https://code.visualstudio.com/docs/copilot/copilot-customization
+ if path == ".github/copilot-instructions.md" {
+ return "inject"
+ }
+
+ // BuildFiles - build and configuration files
+ if strings.HasPrefix(lowerFilename, "makefile") ||
+ strings.HasSuffix(lowerPath, ".vscode/tasks.json") {
+ return "build"
+ }
+
+ // DocumentationFiles - general documentation files
+ if strings.HasPrefix(lowerFilename, "readme") ||
+ strings.HasPrefix(lowerFilename, "contributing") {
+ return "documentation"
+ }
+
+ // GuidanceFiles - other files that provide guidance but aren't critical enough to inject
+ // Non-root directory claude.md files, and other guidance files
+ if (strings.HasPrefix(lowerFilename, "claude.") && strings.HasSuffix(lowerFilename, ".md")) ||
+ (strings.HasPrefix(lowerFilename, "agent.") && strings.HasSuffix(lowerFilename, ".md")) {
+ return "guidance"
+ }
+
+ return ""
+}
+
+// TopExtensions returns the top 5 most common file extensions in the codebase
+func (c *Codebase) TopExtensions() []string {
+ type extCount struct {
+ ext string
+ count int
+ }
+ pairs := make([]extCount, 0, len(c.ExtensionCounts))
+ for ext, count := range c.ExtensionCounts {
+ pairs = append(pairs, extCount{ext, count})
+ }
+
+ // Sort by count (descending), then by extension (ascending)
+ slices.SortFunc(pairs, func(a, b extCount) int {
+ return cmp.Or(
+ -cmp.Compare(a.count, b.count),
+ cmp.Compare(a.ext, b.ext),
+ )
+ })
+
+ const nTop = 5
+ count := min(nTop, len(pairs))
+ result := make([]string, count)
+ for i := range count {
+ result[i] = fmt.Sprintf("%v: %v (%0.0f%%)", pairs[i].ext, pairs[i].count, 100*float64(pairs[i].count)/float64(c.TotalFiles))
+ }
+
+ return result
+}
+
+func scanZero(data []byte, atEOF bool) (advance int, token []byte, err error) {
+ if atEOF && len(data) == 0 {
+ return 0, nil, nil
+ }
+ if i := bytes.IndexByte(data, 0); i >= 0 {
+ // We have a full NUL line.
+ return i + 1, data[0:i], nil
+ }
+ // If we're at EOF, we have a final, non-terminated line. Return it.
+ if atEOF {
+ return len(data), data, nil
+ }
+ // Request more data.
+ return 0, nil, nil
+}
@@ -0,0 +1,238 @@
+package onstart
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "testing"
+)
+
+func TestAnalyzeCodebase(t *testing.T) {
+ t.Run("Basic Analysis", func(t *testing.T) {
+ // Test basic functionality with regular ASCII filenames
+ codebase, err := AnalyzeCodebase(context.Background(), ".")
+ if err != nil {
+ t.Fatalf("AnalyzeCodebase failed: %v", err)
+ }
+
+ if codebase == nil {
+ t.Fatal("Expected non-nil codebase")
+ }
+
+ if codebase.TotalFiles == 0 {
+ t.Error("Expected some files to be analyzed")
+ }
+
+ if len(codebase.ExtensionCounts) == 0 {
+ t.Error("Expected extension counts to be populated")
+ }
+ })
+
+ t.Run("Non-ASCII Filenames", func(t *testing.T) {
+ // Create a temporary directory with unicode filenames for testing
+ tempDir := t.TempDir()
+
+ // Initialize git repository
+ cmd := exec.Command("git", "init")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to init git repo: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.name", "Test User")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to set git user.name: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.email", "test@example.com")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to set git user.email: %v", err)
+ }
+
+ // Configure git to handle unicode filenames properly
+ cmd = exec.Command("git", "config", "core.quotepath", "false")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to set git core.quotepath: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "core.precomposeunicode", "true")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to set git core.precomposeunicode: %v", err)
+ }
+
+ // Create test files with unicode characters dynamically
+ testFiles := map[string]string{
+ "ζ΅θ―ζδ»Ά.go": "// Package test with Chinese characters in filename\npackage test\n\nfunc TestFunction() {\n\t// This is a test file\n}",
+ "cafΓ©.js": "// JavaScript file with French characters\nconsole.log('Hello from cafΓ©!');",
+ "ΡΡΡΡΠΊΠΈΠΉ.py": "# Python file with Russian characters\nprint('ΠΡΠΈΠ²Π΅Ρ ΠΌΠΈΡ!')",
+ "πrocket.md": "# README with Emoji\n\nThis file has an emoji in the filename.",
+ "readme-espaΓ±ol.md": "# Spanish README\n\nEste es un archivo de documentaciΓ³n.",
+ "Γbung.html": "<!DOCTYPE html>\n<html><head><title>German Exercise</title></head><body><h1>Γbung</h1></body></html>",
+ "Makefile-ζ₯ζ¬θͺ": "# Japanese Makefile\nall:\n\techo 'Japanese makefile'",
+ }
+
+ // Create subdirectory
+ subdir := filepath.Join(tempDir, "subdir")
+ err := os.MkdirAll(subdir, 0o755)
+ if err != nil {
+ t.Fatalf("Failed to create subdir: %v", err)
+ }
+
+ // Add file in subdirectory
+ testFiles["subdir/claude.νκ΅μ΄.md"] = "# Korean Claude file\n\nThis is a guidance file with Korean characters."
+
+ // Write all test files
+ for filename, content := range testFiles {
+ fullPath := filepath.Join(tempDir, filename)
+ dir := filepath.Dir(fullPath)
+ if dir != tempDir {
+ err := os.MkdirAll(dir, 0o755)
+ if err != nil {
+ t.Fatalf("Failed to create directory %s: %v", dir, err)
+ }
+ }
+ err := os.WriteFile(fullPath, []byte(content), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write file %s: %v", filename, err)
+ }
+ }
+
+ // Add all files to git at once
+ cmd = exec.Command("git", "add", ".")
+ cmd.Dir = tempDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to add files to git: %v", err)
+ }
+
+ // Test with non-ASCII characters in filenames
+ codebase, err := AnalyzeCodebase(context.Background(), tempDir)
+ if err != nil {
+ t.Fatalf("AnalyzeCodebase failed with non-ASCII filenames: %v", err)
+ }
+
+ if codebase == nil {
+ t.Fatal("Expected non-nil codebase")
+ }
+
+ // We expect 8 files in our temp directory
+ expectedFiles := 8
+ if codebase.TotalFiles != expectedFiles {
+ t.Errorf("Expected %d files, got %d", expectedFiles, codebase.TotalFiles)
+ }
+
+ // Verify extension counts include our non-ASCII files
+ expectedExtensions := map[string]int{
+ ".go": 1, // ζ΅θ―ζδ»Ά.go
+ ".js": 1, // cafΓ©.js
+ ".py": 1, // ΡΡΡΡΠΊΠΈΠΉ.py
+ ".md": 3, // πrocket.md, readme-espaΓ±ol.md, claude.νκ΅μ΄.md
+ ".html": 1, // Γbung.html
+ "<no-extension>": 1, // Makefile-ζ₯ζ¬θͺ
+ }
+
+ for ext, expectedCount := range expectedExtensions {
+ actualCount, exists := codebase.ExtensionCounts[ext]
+ if !exists {
+ t.Errorf("Expected extension %s to be found", ext)
+ continue
+ }
+ if actualCount != expectedCount {
+ t.Errorf("Expected %d files with extension %s, got %d", expectedCount, ext, actualCount)
+ }
+ }
+
+ // Verify file categorization works with non-ASCII filenames
+ // Check build files
+ if !slices.Contains(codebase.BuildFiles, "Makefile-ζ₯ζ¬θͺ") {
+ t.Error("Expected Makefile-ζ₯ζ¬θͺ to be categorized as a build file")
+ }
+
+ // Check documentation files
+ if !slices.Contains(codebase.DocumentationFiles, "readme-espaΓ±ol.md") {
+ t.Error("Expected readme-espaΓ±ol.md to be categorized as a documentation file")
+ }
+
+ // Check guidance files
+ if !slices.Contains(codebase.GuidanceFiles, "subdir/claude.νκ΅μ΄.md") {
+ t.Error("Expected subdir/claude.νκ΅μ΄.md to be categorized as a guidance file")
+ }
+ })
+}
+
+func TestCategorizeFile(t *testing.T) {
+ t.Run("Non-ASCII Filenames", func(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expected string
+ }{
+ {"Chinese Go file", "ζ΅θ―ζδ»Ά.go", ""},
+ {"French JS file", "cafΓ©.js", ""},
+ {"Russian Python file", "ΡΡΡΡΠΊΠΈΠΉ.py", ""},
+ {"Emoji markdown file", "πrocket.md", ""},
+ {"German HTML file", "Γbung.html", ""},
+ {"Japanese Makefile", "Makefile-ζ₯ζ¬θͺ", "build"},
+ {"Spanish README", "readme-espaΓ±ol.md", "documentation"},
+ {"Korean Claude file", "subdir/claude.νκ΅μ΄.md", "guidance"},
+ // Test edge cases with Unicode normalization and combining characters
+ {"Mixed Unicode file", "testδΈζπ.txt", ""},
+ {"Combining characters", "fileΜΜ.go", ""}, // file with combining acute and circumflex accents
+ {"Right-to-left script", "Ω
Ψ±ΨΨ¨Ψ§.py", ""}, // Arabic "hello"
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := categorizeFile(tt.path)
+ if result != tt.expected {
+ t.Errorf("categorizeFile(%q) = %q, want %q", tt.path, result, tt.expected)
+ }
+ })
+ }
+ })
+}
+
+func TestTopExtensions(t *testing.T) {
+ t.Run("With Non-ASCII Files", func(t *testing.T) {
+ // Create a test codebase with known extension counts
+ codebase := &Codebase{
+ ExtensionCounts: map[string]int{
+ ".md": 5, // Most common
+ ".go": 3,
+ ".js": 2,
+ ".py": 1,
+ ".html": 1, // Least common
+ },
+ TotalFiles: 12,
+ }
+
+ topExt := codebase.TopExtensions()
+ if len(topExt) != 5 {
+ t.Errorf("Expected 5 top extensions, got %d", len(topExt))
+ }
+
+ // Check that extensions are sorted by count (descending)
+ expected := []string{
+ ".md: 5 (42%)",
+ ".go: 3 (25%)",
+ ".js: 2 (17%)",
+ ".html: 1 (8%)",
+ ".py: 1 (8%)",
+ }
+
+ for i, expectedExt := range expected {
+ if i >= len(topExt) {
+ t.Errorf("Missing expected extension at index %d: %s", i, expectedExt)
+ continue
+ }
+ if topExt[i] != expectedExt {
+ t.Errorf("Expected extension %q at index %d, got %q", expectedExt, i, topExt[i])
+ }
+ }
+ })
+}
@@ -0,0 +1,626 @@
+package claudetool
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "go/parser"
+ "go/token"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/pkg/diff"
+ "shelley.exe.dev/llm"
+ "sketch.dev/claudetool/editbuf"
+ "sketch.dev/claudetool/patchkit"
+)
+
+// PatchCallback defines the signature for patch tool callbacks.
+// It runs after the patch tool has executed.
+// It receives the patch input and the tool output,
+// and returns a new, possibly altered tool output.
+type PatchCallback func(input PatchInput, output llm.ToolOut) llm.ToolOut
+
+// PatchTool specifies an llm.Tool for patching files.
+// PatchTools are not concurrency-safe.
+type PatchTool struct {
+ Callback PatchCallback // may be nil
+ // WorkingDir is the shared mutable working directory.
+ WorkingDir *MutableWorkingDir
+ // Simplified indicates whether to use the simplified input schema.
+ // Helpful for weaker models.
+ Simplified bool
+ // ClipboardEnabled controls whether clipboard functionality is enabled.
+ // Ignored if Simplified is true.
+ // NB: The actual implementation of the patch tool is unchanged,
+ // this flag merely extends the description and input schema to include the clipboard operations.
+ ClipboardEnabled bool
+ // clipboards stores clipboard name -> text
+ clipboards map[string]string
+}
+
+// getWorkingDir returns the current working directory.
+func (p *PatchTool) getWorkingDir() string {
+ return p.WorkingDir.Get()
+}
+
+// Tool returns an llm.Tool based on p.
+func (p *PatchTool) Tool() *llm.Tool {
+ description := PatchBaseDescription + PatchUsageNotes
+ schema := PatchStandardInputSchema
+ switch {
+ case p.Simplified:
+ schema = PatchStandardSimplifiedSchema
+ case p.ClipboardEnabled:
+ description = PatchBaseDescription + PatchClipboardDescription + PatchUsageNotes
+ schema = PatchClipboardInputSchema
+ }
+ return &llm.Tool{
+ Name: PatchName,
+ Description: strings.TrimSpace(description),
+ InputSchema: llm.MustSchema(schema),
+ Run: p.Run,
+ }
+}
+
+const (
+ PatchName = "patch"
+ PatchBaseDescription = `
+File modification tool for precise text edits.
+
+Operations:
+- replace: Substitute unique text with new content
+- append_eof: Append new text at the end of the file
+- prepend_bof: Insert new text at the beginning of the file
+- overwrite: Replace the entire file with new content (automatically creates the file)
+`
+
+ PatchClipboardDescription = `
+Clipboard:
+- toClipboard: Store oldText to a named clipboard before the operation
+- fromClipboard: Use clipboard content as newText (ignores provided newText)
+- Clipboards persist across patch calls
+- Always use clipboards when moving/copying code (within or across files), even when the moved/copied code will also have edits.
+ This prevents transcription errors and distinguishes intentional changes from unintentional changes.
+
+Indentation adjustment:
+- reindent applies to whatever text is being inserted
+- First strips the specified prefix from each line, then adds the new prefix
+- Useful when moving code from one indentation to another
+
+Recipes:
+- cut: replace with empty newText and toClipboard
+- copy: replace with toClipboard and fromClipboard using the same clipboard name
+- paste: replace with fromClipboard
+- in-place indentation change: same as copy, but add indentation adjustment
+`
+
+ PatchUsageNotes = `
+Usage notes:
+- All inputs are interpreted literally (no automatic newline or whitespace handling)
+- For replace operations, oldText must appear EXACTLY ONCE in the file
+`
+
+ // If you modify this, update the termui template for prettier rendering.
+ PatchStandardInputSchema = `
+{
+ "type": "object",
+ "required": ["path", "patches"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the file to patch"
+ },
+ "patches": {
+ "type": "array",
+ "description": "List of patch requests to apply",
+ "items": {
+ "type": "object",
+ "required": ["operation", "newText"],
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+ "description": "Type of operation to perform"
+ },
+ "oldText": {
+ "type": "string",
+ "description": "Text to locate for the operation (must be unique in file, required for replace)"
+ },
+ "newText": {
+ "type": "string",
+ "description": "The new text to use (empty for deletions)"
+ }
+ }
+ }
+ }
+ }
+}
+`
+
+ PatchStandardSimplifiedSchema = `{
+ "type": "object",
+ "required": ["path", "patch"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the file to patch"
+ },
+ "patch": {
+ "type": "object",
+ "required": ["operation", "newText"],
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+ "description": "Type of operation to perform"
+ },
+ "oldText": {
+ "type": "string",
+ "description": "Text to locate for the operation (must be unique in file, required for replace)"
+ },
+ "newText": {
+ "type": "string",
+ "description": "The new text to use (empty for deletions)"
+ }
+ }
+ }
+ }
+}`
+
+ PatchClipboardInputSchema = `
+{
+ "type": "object",
+ "required": ["path", "patches"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the file to patch"
+ },
+ "patches": {
+ "type": "array",
+ "description": "List of patch requests to apply",
+ "items": {
+ "type": "object",
+ "required": ["operation"],
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+ "description": "Type of operation to perform"
+ },
+ "oldText": {
+ "type": "string",
+ "description": "Text to locate (must be unique in file, required for replace)"
+ },
+ "newText": {
+ "type": "string",
+ "description": "The new text to use (empty for deletions, leave empty if fromClipboard is set)"
+ },
+ "toClipboard": {
+ "type": "string",
+ "description": "Save oldText to this named clipboard before the operation"
+ },
+ "fromClipboard": {
+ "type": "string",
+ "description": "Use content from this clipboard as newText (overrides newText field)"
+ },
+ "reindent": {
+ "type": "object",
+ "description": "Modify indentation of the inserted text (newText or fromClipboard) before insertion",
+ "properties": {
+ "strip": {
+ "type": "string",
+ "description": "Remove this prefix from each non-empty line before insertion"
+ },
+ "add": {
+ "type": "string",
+ "description": "Add this prefix to each non-empty line after stripping"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`
+)
+
+// TODO: maybe rename PatchRequest to PatchOperation or PatchSpec or PatchPart or just Patch?
+
+// PatchInput represents the input structure for patch operations.
+type PatchInput struct {
+ Path string `json:"path"`
+ Patches []PatchRequest `json:"patches"`
+}
+
+// PatchInputOne is a simplified version of PatchInput for single patch operations.
+type PatchInputOne struct {
+ Path string `json:"path"`
+ Patches *PatchRequest `json:"patches"`
+}
+
+// PatchInputOneSingular is PatchInputOne with a better name for the singular case.
+type PatchInputOneSingular struct {
+ Path string `json:"path"`
+ Patch *PatchRequest `json:"patch"`
+}
+
+type PatchInputOneString struct {
+ Path string `json:"path"`
+ Patches string `json:"patches"` // contains Patches as a JSON string π€¦
+}
+
+// PatchRequest represents a single patch operation.
+type PatchRequest struct {
+ Operation string `json:"operation"`
+ OldText string `json:"oldText,omitempty"`
+ NewText string `json:"newText,omitempty"`
+ ToClipboard string `json:"toClipboard,omitempty"`
+ FromClipboard string `json:"fromClipboard,omitempty"`
+ Reindent *Reindent `json:"reindent,omitempty"`
+}
+
+// Reindent represents indentation adjustment configuration.
+type Reindent struct {
+ // TODO: it might be nice to make this more flexible,
+ // so it can e.g. strip all whitespace,
+ // or strip the prefix only on lines where it is present,
+ // or strip based on a regex.
+ Strip string `json:"strip,omitempty"`
+ Add string `json:"add,omitempty"`
+}
+
+// Run implements the patch tool logic.
+func (p *PatchTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ if p.clipboards == nil {
+ p.clipboards = make(map[string]string)
+ }
+ input, err := p.patchParse(m)
+ var output llm.ToolOut
+ if err != nil {
+ output = llm.ErrorToolOut(err)
+ } else {
+ output = p.patchRun(ctx, &input)
+ }
+ if p.Callback != nil {
+ return p.Callback(input, output)
+ }
+ return output
+}
+
+// patchParse parses the input message into a PatchInput structure.
+// It accepts a few different formats, because empirically,
+// LLMs sometimes generate slightly different JSON structures,
+// and we may as well accept such near misses.
+func (p *PatchTool) patchParse(m json.RawMessage) (PatchInput, error) {
+ var input PatchInput
+ originalErr := json.Unmarshal(m, &input)
+ if originalErr == nil && len(input.Patches) > 0 {
+ return input, nil
+ }
+ var inputOne PatchInputOne
+ if err := json.Unmarshal(m, &inputOne); err == nil && inputOne.Patches != nil {
+ return PatchInput{Path: inputOne.Path, Patches: []PatchRequest{*inputOne.Patches}}, nil
+ } else if originalErr == nil {
+ originalErr = err
+ }
+ var inputOneSingular PatchInputOneSingular
+ if err := json.Unmarshal(m, &inputOneSingular); err == nil && inputOneSingular.Patch != nil {
+ return PatchInput{Path: inputOneSingular.Path, Patches: []PatchRequest{*inputOneSingular.Patch}}, nil
+ } else if originalErr == nil {
+ originalErr = err
+ }
+ var inputOneString PatchInputOneString
+ if err := json.Unmarshal(m, &inputOneString); err == nil {
+ var onePatch PatchRequest
+ if err := json.Unmarshal([]byte(inputOneString.Patches), &onePatch); err == nil && onePatch.Operation != "" {
+ return PatchInput{Path: inputOneString.Path, Patches: []PatchRequest{onePatch}}, nil
+ } else if originalErr == nil {
+ originalErr = err
+ }
+ var patches []PatchRequest
+ if err := json.Unmarshal([]byte(inputOneString.Patches), &patches); err == nil {
+ return PatchInput{Path: inputOneString.Path, Patches: patches}, nil
+ } else if originalErr == nil {
+ originalErr = err
+ }
+ }
+ return PatchInput{}, fmt.Errorf("failed to unmarshal patch input: %w\nJSON: %s", originalErr, string(m))
+}
+
+// patchRun implements the guts of the patch tool.
+// It populates input from m.
+func (p *PatchTool) patchRun(ctx context.Context, input *PatchInput) llm.ToolOut {
+ path := input.Path
+ if !filepath.IsAbs(input.Path) {
+ // Use shared WorkingDir if available, then context, then Pwd fallback
+ pwd := p.getWorkingDir()
+ path = filepath.Join(pwd, input.Path)
+ }
+ input.Path = path
+ if len(input.Patches) == 0 {
+ return llm.ErrorToolOut(fmt.Errorf("no patches provided"))
+ }
+ // TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
+
+ orig, err := os.ReadFile(input.Path)
+ // If the file doesn't exist, we can still apply patches
+ // that don't require finding existing text.
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ for _, patch := range input.Patches {
+ switch patch.Operation {
+ case "prepend_bof", "append_eof", "overwrite":
+ default:
+ return llm.ErrorfToolOut("file %q does not exist", input.Path)
+ }
+ }
+ case err != nil:
+ return llm.ErrorfToolOut("failed to read file %q: %w", input.Path, err)
+ }
+
+ likelyGoFile := strings.HasSuffix(input.Path, ".go")
+
+ autogenerated := likelyGoFile && IsAutogeneratedGoFile(orig)
+
+ origStr := string(orig)
+ // Process the patches "simultaneously", minimizing them along the way.
+ // Claude generates patches that interact with each other.
+ buf := editbuf.NewBuffer(orig)
+
+ // TODO: is it better to apply the patches that apply cleanly and report on the failures?
+ // or instead have it be all-or-nothing?
+ // For now, it is all-or-nothing.
+ // TODO: when the model gets into a "cannot apply patch" cycle of doom, how do we get it unstuck?
+ // Also: how do we detect that it's in a cycle?
+ var patchErr error
+
+ var clipboardsModified []string
+ updateToClipboard := func(patch PatchRequest, spec *patchkit.Spec) {
+ if patch.ToClipboard == "" {
+ return
+ }
+ // Update clipboard with the actual matched text
+ matchedOldText := origStr[spec.Off : spec.Off+spec.Len]
+ p.clipboards[patch.ToClipboard] = matchedOldText
+ clipboardsModified = append(clipboardsModified, fmt.Sprintf(`<clipboard_modified name="%s"><message>clipboard contents altered in order to match uniquely</message><new_contents>%q</new_contents></clipboard_modified>`, patch.ToClipboard, matchedOldText))
+ }
+
+ for i, patch := range input.Patches {
+ // Process toClipboard first, so that copy works
+ if patch.ToClipboard != "" {
+ if patch.Operation != "replace" {
+ return llm.ErrorfToolOut("toClipboard (%s): can only be used with replace operation", patch.ToClipboard)
+ }
+ if patch.OldText == "" {
+ return llm.ErrorfToolOut("toClipboard (%s): oldText cannot be empty when using toClipboard", patch.ToClipboard)
+ }
+ p.clipboards[patch.ToClipboard] = patch.OldText
+ }
+
+ // Handle fromClipboard
+ newText := patch.NewText
+ if patch.FromClipboard != "" {
+ clipboardText, ok := p.clipboards[patch.FromClipboard]
+ if !ok {
+ return llm.ErrorfToolOut("fromClipboard (%s): no clipboard with that name", patch.FromClipboard)
+ }
+ newText = clipboardText
+ }
+
+ // Apply indentation adjustment if specified
+ if patch.Reindent != nil {
+ reindentedText, err := reindent(newText, patch.Reindent)
+ if err != nil {
+ return llm.ErrorfToolOut("reindent(%q -> %q): %w", patch.Reindent.Strip, patch.Reindent.Add, err)
+ }
+ newText = reindentedText
+ }
+
+ switch patch.Operation {
+ case "prepend_bof":
+ buf.Insert(0, newText)
+ case "append_eof":
+ buf.Insert(len(orig), newText)
+ case "overwrite":
+ buf.Replace(0, len(orig), newText)
+ case "replace":
+ if patch.OldText == "" {
+ return llm.ErrorfToolOut("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
+ }
+
+ // Attempt to apply the patch.
+ spec, count := patchkit.Unique(origStr, patch.OldText, newText)
+ switch count {
+ case 0:
+ // no matches, maybe recoverable, continued below
+ case 1:
+ // exact match, apply
+ slog.DebugContext(ctx, "patch_applied", "method", "unique")
+ spec.ApplyToEditBuf(buf)
+ continue
+ case 2:
+ // multiple matches
+ patchErr = errors.Join(patchErr, fmt.Errorf("old text not unique:\n%s", patch.OldText))
+ continue
+ default:
+ slog.ErrorContext(ctx, "unique returned unexpected count", "count", count)
+ patchErr = errors.Join(patchErr, fmt.Errorf("internal error"))
+ continue
+ }
+
+ // The following recovery mechanisms are heuristic.
+ // They aren't perfect, but they appear safe,
+ // and the cases they cover appear with some regularity.
+
+ // Try adjusting the whitespace prefix.
+ spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, newText)
+ if ok {
+ slog.DebugContext(ctx, "patch_applied", "method", "unique_dedent")
+ spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
+ continue
+ }
+
+ // Try ignoring leading/trailing whitespace in a semantically safe way.
+ spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, newText)
+ if ok {
+ slog.DebugContext(ctx, "patch_applied", "method", "unique_in_valid_go")
+ spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
+ continue
+ }
+
+ // Try ignoring semantically insignificant whitespace.
+ spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, newText)
+ if ok {
+ slog.DebugContext(ctx, "patch_applied", "method", "unique_go_tokens")
+ spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
+ continue
+ }
+
+ // Try trimming the first line of the patch, if we can do so safely.
+ spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, newText)
+ if ok {
+ slog.DebugContext(ctx, "patch_applied", "method", "unique_trim")
+ spec.ApplyToEditBuf(buf)
+ // Do NOT call updateToClipboard here,
+ // because the trimmed text may vary significantly from the original text.
+ continue
+ }
+
+ // No dice.
+ patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
+ continue
+ default:
+ return llm.ErrorfToolOut("unrecognized operation %q", patch.Operation)
+ }
+ }
+
+ if patchErr != nil {
+ errorMsg := patchErr.Error()
+ for _, msg := range clipboardsModified {
+ errorMsg += "\n" + msg
+ }
+ return llm.ErrorToolOut(fmt.Errorf("%s", errorMsg))
+ }
+
+ patched, err := buf.Bytes()
+ if err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
+ return llm.ErrorfToolOut("failed to create directory %q: %w", filepath.Dir(input.Path), err)
+ }
+ if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
+ return llm.ErrorfToolOut("failed to write patched contents to file %q: %w", input.Path, err)
+ }
+
+ response := new(strings.Builder)
+ fmt.Fprintf(response, "<patches_applied>all</patches_applied>\n")
+ for _, msg := range clipboardsModified {
+ fmt.Fprintln(response, msg)
+ }
+
+ if autogenerated {
+ fmt.Fprintf(response, "<warning>%q appears to be autogenerated. Patches were applied anyway.</warning>\n", input.Path)
+ }
+
+ diff := generateUnifiedDiff(input.Path, string(orig), string(patched))
+
+ // TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
+ return llm.ToolOut{
+ LLMContent: llm.TextContent(response.String()),
+ Display: diff,
+ }
+}
+
+// IsAutogeneratedGoFile reports whether a Go file has markers indicating it was autogenerated.
+func IsAutogeneratedGoFile(buf []byte) bool {
+ for _, sig := range autogeneratedSignals {
+ if bytes.Contains(buf, []byte(sig)) {
+ return true
+ }
+ }
+
+ // https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
+ // "This line must appear before the first non-comment, non-blank text in the file."
+ // Approximate that by looking for it at the top of the file, before the last of the imports.
+ // (Sometimes people put it after the package declaration, because of course they do.)
+ // At least in the imports region we know it's not part of their actual code;
+ // we don't want to ignore the generator (which also includes these strings!),
+ // just the generated code.
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, "x.go", buf, parser.ImportsOnly|parser.ParseComments)
+ if err == nil {
+ for _, cg := range f.Comments {
+ t := strings.ToLower(cg.Text())
+ for _, sig := range autogeneratedHeaderSignals {
+ if strings.Contains(t, sig) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+// autogeneratedSignals are signals that a file is autogenerated, when present anywhere in the file.
+var autogeneratedSignals = [][]byte{
+ []byte("\nfunc bindataRead("), // pre-embed bindata packed file
+}
+
+// autogeneratedHeaderSignals are signals that a file is autogenerated, when present at the top of the file.
+var autogeneratedHeaderSignals = []string{
+ // canonical would be `(?m)^// Code generated .* DO NOT EDIT\.$`
+ // but people screw it up, a lot, so be more lenient
+ strings.ToLower("generate"),
+ strings.ToLower("DO NOT EDIT"),
+ strings.ToLower("export by"),
+}
+
+func generateUnifiedDiff(filePath, original, patched string) string {
+ buf := new(strings.Builder)
+ err := diff.Text(filePath, filePath, original, patched, buf)
+ if err != nil {
+ return fmt.Sprintf("(diff generation failed: %v)\n", err)
+ }
+ return buf.String()
+}
+
+// reindent applies indentation adjustments to text.
+func reindent(text string, adj *Reindent) (string, error) {
+ if adj == nil {
+ return text, nil
+ }
+
+ lines := strings.Split(text, "\n")
+
+ for i, line := range lines {
+ if line == "" {
+ continue
+ }
+ var ok bool
+ lines[i], ok = strings.CutPrefix(line, adj.Strip)
+ if !ok {
+ return "", fmt.Errorf("strip precondition failed: line %q does not start with %q", line, adj.Strip)
+ }
+ }
+
+ for i, line := range lines {
+ if line == "" {
+ continue
+ }
+ lines[i] = adj.Add + line
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
@@ -0,0 +1,625 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "shelley.exe.dev/llm"
+)
+
+func TestPatchTool_BasicOperations(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ // Test overwrite operation (creates new file)
+ testFile := filepath.Join(tempDir, "test.txt")
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "Hello World\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("overwrite failed: %v", result.Error)
+ }
+
+ content, err := os.ReadFile(testFile)
+ if err != nil {
+ t.Fatalf("failed to read file: %v", err)
+ }
+ if string(content) != "Hello World\n" {
+ t.Errorf("expected 'Hello World\\n', got %q", string(content))
+ }
+
+ // Test replace operation
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "World",
+ NewText: "Patch",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("replace failed: %v", result.Error)
+ }
+
+ content, _ = os.ReadFile(testFile)
+ if string(content) != "Hello Patch\n" {
+ t.Errorf("expected 'Hello Patch\\n', got %q", string(content))
+ }
+
+ // Test append_eof operation
+ input.Patches = []PatchRequest{{
+ Operation: "append_eof",
+ NewText: "Appended line\n",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("append_eof failed: %v", result.Error)
+ }
+
+ content, _ = os.ReadFile(testFile)
+ expected := "Hello Patch\nAppended line\n"
+ if string(content) != expected {
+ t.Errorf("expected %q, got %q", expected, string(content))
+ }
+
+ // Test prepend_bof operation
+ input.Patches = []PatchRequest{{
+ Operation: "prepend_bof",
+ NewText: "Prepended line\n",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("prepend_bof failed: %v", result.Error)
+ }
+
+ content, _ = os.ReadFile(testFile)
+ expected = "Prepended line\nHello Patch\nAppended line\n"
+ if string(content) != expected {
+ t.Errorf("expected %q, got %q", expected, string(content))
+ }
+}
+
+func TestPatchTool_ClipboardOperations(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "clipboard.txt")
+
+ // Create initial content
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "function original() {\n return 'original';\n}\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("initial overwrite failed: %v", result.Error)
+ }
+
+ // Test toClipboard operation
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "function original() {\n return 'original';\n}",
+ NewText: "function renamed() {\n return 'renamed';\n}",
+ ToClipboard: "saved_func",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("toClipboard failed: %v", result.Error)
+ }
+
+ // Test fromClipboard operation
+ input.Patches = []PatchRequest{{
+ Operation: "append_eof",
+ FromClipboard: "saved_func",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("fromClipboard failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ if !strings.Contains(string(content), "function original()") {
+ t.Error("clipboard content not restored properly")
+ }
+}
+
+func TestPatchTool_IndentationAdjustment(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "indent.go")
+
+ // Create file with tab indentation
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "package main\n\nfunc main() {\n\tif true {\n\t\t// placeholder\n\t}\n}\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("initial setup failed: %v", result.Error)
+ }
+
+ // Test indentation adjustment: convert spaces to tabs
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "// placeholder",
+ NewText: " fmt.Println(\"hello\")\n fmt.Println(\"world\")",
+ Reindent: &Reindent{
+ Strip: " ",
+ Add: "\t\t",
+ },
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("indentation adjustment failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ expected := "\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")"
+ if !strings.Contains(string(content), expected) {
+ t.Errorf("indentation not adjusted correctly, got:\n%s", string(content))
+ }
+}
+
+func TestPatchTool_FuzzyMatching(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "fuzzy.go")
+
+ // Create Go file with specific indentation
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "package main\n\nfunc test() {\n\tif condition {\n\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")\n\t}\n}\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("initial setup failed: %v", result.Error)
+ }
+
+ // Test fuzzy matching with different whitespace
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "if condition {\n fmt.Println(\"hello\")\n fmt.Println(\"world\")\n }", // spaces instead of tabs
+ NewText: "if condition {\n\t\tfmt.Println(\"modified\")\n\t}",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("fuzzy matching failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ if !strings.Contains(string(content), "modified") {
+ t.Error("fuzzy matching did not work")
+ }
+}
+
+func TestPatchTool_ErrorCases(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "error.txt")
+
+ // Test replace operation on non-existent file
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "replace",
+ OldText: "something",
+ NewText: "else",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error == nil {
+ t.Error("expected error for replace on non-existent file")
+ }
+
+ // Create file with duplicate text
+ input.Patches = []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "duplicate\nduplicate\n",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("failed to create test file: %v", result.Error)
+ }
+
+ // Test non-unique text
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "duplicate",
+ NewText: "unique",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error == nil || !strings.Contains(result.Error.Error(), "not unique") {
+ t.Error("expected non-unique error")
+ }
+
+ // Test missing text
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "nonexistent",
+ NewText: "something",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error == nil || !strings.Contains(result.Error.Error(), "not found") {
+ t.Error("expected not found error")
+ }
+
+ // Test invalid clipboard reference
+ input.Patches = []PatchRequest{{
+ Operation: "append_eof",
+ FromClipboard: "nonexistent",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error == nil || !strings.Contains(result.Error.Error(), "clipboard") {
+ t.Error("expected clipboard error")
+ }
+}
+
+func TestPatchTool_FlexibleInputParsing(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "flexible.txt")
+
+ // Test single patch format (PatchInputOne)
+ inputOne := PatchInputOne{
+ Path: testFile,
+ Patches: &PatchRequest{
+ Operation: "overwrite",
+ NewText: "Single patch format\n",
+ },
+ }
+
+ msg, _ := json.Marshal(inputOne)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("single patch format failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ if string(content) != "Single patch format\n" {
+ t.Error("single patch format did not work")
+ }
+
+ // Test string patch format (PatchInputOneString)
+ patchStr := `{"operation": "replace", "oldText": "Single", "newText": "Modified"}`
+ inputStr := PatchInputOneString{
+ Path: testFile,
+ Patches: patchStr,
+ }
+
+ msg, _ = json.Marshal(inputStr)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("string patch format failed: %v", result.Error)
+ }
+
+ content, _ = os.ReadFile(testFile)
+ if !strings.Contains(string(content), "Modified") {
+ t.Error("string patch format did not work")
+ }
+}
+
+func TestPatchTool_AutogeneratedDetection(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "generated.go")
+
+ // Create autogenerated file
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "// Code generated by tool. DO NOT EDIT.\npackage main\n\nfunc generated() {}\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("failed to create generated file: %v", result.Error)
+ }
+
+ // Test patching autogenerated file (should warn but work)
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "func generated() {}",
+ NewText: "func modified() {}",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("patching generated file failed: %v", result.Error)
+ }
+
+ if len(result.LLMContent) == 0 || !strings.Contains(result.LLMContent[0].Text, "autogenerated") {
+ t.Error("expected autogenerated warning")
+ }
+}
+
+func TestPatchTool_MultiplePatches(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "multi.go")
+ var msg []byte
+ var result llm.ToolOut
+
+ // Apply multiple patches - first create file, then modify
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "package main\n\nfunc first() {\n\tprintln(\"first\")\n}\n\nfunc second() {\n\tprintln(\"second\")\n}\n",
+ }},
+ }
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("failed to create initial file: %v", result.Error)
+ }
+
+ // Now apply multiple patches in one call
+ input.Patches = []PatchRequest{
+ {
+ Operation: "replace",
+ OldText: "println(\"first\")",
+ NewText: "println(\"ONE\")",
+ },
+ {
+ Operation: "replace",
+ OldText: "println(\"second\")",
+ NewText: "println(\"TWO\")",
+ },
+ {
+ Operation: "append_eof",
+ NewText: "\n// Multiple patches applied\n",
+ },
+ }
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("multiple patches failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ contentStr := string(content)
+ if !strings.Contains(contentStr, "ONE") || !strings.Contains(contentStr, "TWO") {
+ t.Error("multiple patches not applied correctly")
+ }
+ if !strings.Contains(contentStr, "Multiple patches applied") {
+ t.Error("append_eof in multiple patches not applied")
+ }
+}
+
+func TestPatchTool_CopyRecipe(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "copy.txt")
+
+ // Create initial content
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "original text",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("failed to create file: %v", result.Error)
+ }
+
+ // Test copy recipe (toClipboard + fromClipboard with same name)
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "original text",
+ NewText: "replaced text",
+ ToClipboard: "copy_test",
+ FromClipboard: "copy_test",
+ }}
+
+ msg, _ = json.Marshal(input)
+ result = patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("copy recipe failed: %v", result.Error)
+ }
+
+ content, _ := os.ReadFile(testFile)
+ // The copy recipe should preserve the original text
+ if string(content) != "original text" {
+ t.Errorf("copy recipe failed, expected 'original text', got %q", string(content))
+ }
+}
+
+func TestPatchTool_RelativePaths(t *testing.T) {
+ tempDir := t.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ // Test relative path resolution
+ input := PatchInput{
+ Path: "relative.txt", // relative path
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "relative path test\n",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ t.Fatalf("relative path failed: %v", result.Error)
+ }
+
+ // Check file was created in correct location
+ expectedPath := filepath.Join(tempDir, "relative.txt")
+ content, err := os.ReadFile(expectedPath)
+ if err != nil {
+ t.Fatalf("file not created at expected path: %v", err)
+ }
+ if string(content) != "relative path test\n" {
+ t.Error("relative path file content incorrect")
+ }
+}
+
+// Benchmark basic patch operations
+func BenchmarkPatchTool_BasicOperations(b *testing.B) {
+ tempDir := b.TempDir()
+ patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+ ctx := context.Background()
+
+ testFile := filepath.Join(tempDir, "bench.go")
+ initialContent := "package main\n\nfunc test() {\n\tfor i := 0; i < 100; i++ {\n\t\tfmt.Println(i)\n\t}\n}\n"
+
+ // Setup
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: initialContent,
+ }},
+ }
+ msg, _ := json.Marshal(input)
+ patch.Run(ctx, msg)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ // Benchmark replace operation
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "fmt.Println(i)",
+ NewText: "fmt.Printf(\"%d\\n\", i)",
+ }}
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+ if result.Error != nil {
+ b.Fatalf("benchmark failed: %v", result.Error)
+ }
+
+ // Reset for next iteration
+ input.Patches = []PatchRequest{{
+ Operation: "replace",
+ OldText: "fmt.Printf(\"%d\\n\", i)",
+ NewText: "fmt.Println(i)",
+ }}
+ msg, _ = json.Marshal(input)
+ patch.Run(ctx, msg)
+ }
+}
+
+func TestPatchTool_CallbackFunction(t *testing.T) {
+ tempDir := t.TempDir()
+ callbackCalled := false
+ var capturedInput PatchInput
+ var capturedOutput llm.ToolOut
+
+ patch := &PatchTool{
+ WorkingDir: NewMutableWorkingDir(tempDir),
+ Callback: func(input PatchInput, output llm.ToolOut) llm.ToolOut {
+ callbackCalled = true
+ capturedInput = input
+ capturedOutput = output
+ // Modify the output
+ output.LLMContent = llm.TextContent("Modified by callback")
+ return output
+ },
+ }
+
+ ctx := context.Background()
+ testFile := filepath.Join(tempDir, "callback.txt")
+
+ input := PatchInput{
+ Path: testFile,
+ Patches: []PatchRequest{{
+ Operation: "overwrite",
+ NewText: "callback test",
+ }},
+ }
+
+ msg, _ := json.Marshal(input)
+ result := patch.Run(ctx, msg)
+
+ if !callbackCalled {
+ t.Error("callback was not called")
+ }
+
+ if capturedInput.Path != testFile {
+ t.Error("callback did not receive correct input")
+ }
+
+ if len(result.LLMContent) == 0 || result.LLMContent[0].Text != "Modified by callback" {
+ t.Error("callback did not modify output correctly")
+ }
+
+ if capturedOutput.Error != nil {
+ t.Errorf("callback received error: %v", capturedOutput.Error)
+ }
+}
@@ -0,0 +1,415 @@
+package patchkit
+
+import (
+ "fmt"
+ "go/scanner"
+ "go/token"
+ "slices"
+ "strings"
+ "unicode"
+
+ "sketch.dev/claudetool/editbuf"
+)
+
+// A Spec specifies a single patch.
+type Spec struct {
+ Off int // Byte offset to apply the replacement
+ Len int // Length of the replacement
+ Src string // Original string (for debugging)
+ Old string // Search string
+ New string // Replacement string
+}
+
+// Unique generates a patch spec to apply op, given a unique occurrence of needle in haystack and replacement text replace.
+// It reports the number of matches found for needle in haystack: 0, 1, or 2 (for any value > 1).
+func Unique(haystack, needle, replace string) (*Spec, int) {
+ prefix, rest, ok := strings.Cut(haystack, needle)
+ if !ok {
+ return nil, 0
+ }
+ if strings.Contains(rest, needle) {
+ return nil, 2
+ }
+ s := &Spec{
+ Off: len(prefix),
+ Len: len(needle),
+ Src: haystack,
+ Old: needle,
+ New: replace,
+ }
+ return s, 1
+}
+
+// minimize reduces the size of the patch by removing any shared prefix and suffix.
+func (s *Spec) minimize() {
+ pre := commonPrefixLen(s.Old, s.New)
+ s.Off += pre
+ s.Len -= pre
+ s.Old = s.Old[pre:]
+ s.New = s.New[pre:]
+ suf := commonSuffixLen(s.Old, s.New)
+ s.Len -= suf
+ s.Old = s.Old[:len(s.Old)-suf]
+ s.New = s.New[:len(s.New)-suf]
+}
+
+// ApplyToEditBuf applies the patch to the given edit buffer.
+func (s *Spec) ApplyToEditBuf(buf *editbuf.Buffer) {
+ s.minimize()
+ buf.Replace(s.Off, s.Off+s.Len, s.New)
+}
+
+// UniqueDedent is Unique, but with flexibility around consistent whitespace prefix changes.
+// Unlike Unique, which returns a count of matches,
+// UniqueDedent returns a boolean indicating whether a unique match was found.
+// It is for LLMs that have a hard time reliably reproducing uniform whitespace prefixes.
+// For example, they may generate 8 spaces instead of 6 for all relevant lines.
+// UniqueDedent adjusts the needle's whitespace prefix to match the haystack's
+// and then replaces the unique instance of needle in haystack with replacement.
+func UniqueDedent(haystack, needle, replace string) (*Spec, bool) {
+ // TODO: this all definitely admits of some optimization
+ haystackLines := slices.Collect(strings.Lines(haystack))
+ needleLines := slices.Collect(strings.Lines(needle))
+ match := uniqueTrimmedLineMatch(haystackLines, needleLines)
+ if match == -1 {
+ return nil, false
+ }
+ // We now systematically adjust needle's whitespace prefix to match haystack.
+ // The first line gets special treatment, because its leading whitespace is irrelevant,
+ // and models often skip past it (or part of it).
+ if len(needleLines) == 0 {
+ return nil, false
+ }
+ // First line: cut leading whitespace and make corresponding fixes to replacement.
+ // The leading whitespace will come out in the wash in Unique.
+ // We need to make corresponding fixes to the replacement.
+ nl0 := needleLines[0]
+ noWS := strings.TrimLeftFunc(nl0, unicode.IsSpace)
+ ws0, _ := strings.CutSuffix(nl0, noWS) // can't fail
+ rest, ok := strings.CutPrefix(replace, ws0)
+ if ok {
+ // Adjust needle and replacement in tandem.
+ nl0 = noWS
+ replace = rest
+ }
+ // Calculate common whitespace prefixes for the rest.
+ haystackPrefix := commonWhitespacePrefix(haystackLines[match : match+len(needleLines)])
+ needlePrefix := commonWhitespacePrefix(needleLines[1:])
+ nbuf := new(strings.Builder)
+ for i, line := range needleLines {
+ if i == 0 {
+ nbuf.WriteString(nl0)
+ continue
+ }
+ // Allow empty (newline-only) lines not to be prefixed.
+ if strings.TrimRight(line, "\n\r") == "" {
+ nbuf.WriteString(line)
+ continue
+ }
+ // Swap in haystackPrefix for needlePrefix.
+ nbuf.WriteString(haystackPrefix)
+ nbuf.WriteString(line[len(needlePrefix):])
+ }
+ // Do a replacement with our new-and-improved needle.
+ needle = nbuf.String()
+ spec, count := Unique(haystack, needle, replace)
+ if count != 1 {
+ return nil, false
+ }
+ return spec, true
+}
+
+type tok struct {
+ pos token.Position
+ tok token.Token
+ lit string
+}
+
+func (t tok) String() string {
+ if t.lit == "" {
+ return fmt.Sprintf("%s", t.tok)
+ }
+ return fmt.Sprintf("%s(%q)", t.tok, t.lit)
+}
+
+func tokenize(code string) ([]tok, bool) {
+ var s scanner.Scanner
+ fset := token.NewFileSet()
+ file := fset.AddFile("", fset.Base(), len(code))
+ s.Init(file, []byte(code), nil, scanner.ScanComments)
+ var tokens []tok
+ for {
+ pos, t, lit := s.Scan()
+ if s.ErrorCount > 0 {
+ return nil, false // invalid Go code (or not Go code at all)
+ }
+ if t == token.EOF {
+ return tokens, true
+ }
+ tokens = append(tokens, tok{pos: fset.PositionFor(pos, false), tok: t, lit: lit})
+ }
+}
+
+func tokensEqual(a, b []tok) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ at, bt := a[i], b[i]
+ // positions are expected to differ
+ if at.tok != bt.tok || at.lit != bt.lit {
+ return false
+ }
+ }
+ return true
+}
+
+func tokensUniqueMatch(haystack, needle []tok) int {
+ // TODO: optimize
+ match := -1
+ for i := range haystack {
+ rest := haystack[i:]
+ if len(rest) < len(needle) {
+ break
+ }
+ rest = rest[:len(needle)]
+ if !tokensEqual(rest, needle) {
+ continue
+ }
+ if match != -1 {
+ return -1 // multiple matches
+ }
+ match = i
+ }
+ return match
+}
+
+// UniqueGoTokens is Unique, but with flexibility around all insignificant whitespace.
+// Like UniqueDedent, it returns a boolean indicating whether a unique match was found.
+// It is safe (enough) because it ensures that the needle alterations occurs only in places
+// where whitespace is not semantically significant.
+// In practice, this appears safe.
+func UniqueGoTokens(haystack, needle, replace string) (*Spec, bool) {
+ nt, ok := tokenize(needle)
+ if !ok {
+ return nil, false
+ }
+ ht, ok := tokenize(haystack)
+ if !ok {
+ return nil, false
+ }
+ match := tokensUniqueMatch(ht, nt)
+ if match == -1 {
+ return nil, false
+ }
+ matchEnd := match + len(nt) - 1
+ start := ht[match].pos.Offset
+ needle = haystack[start:]
+ if matchEnd+1 < len(ht) {
+ // todo: handle match at very end of file
+ end := ht[matchEnd+1].pos.Offset
+ needle = needle[:end-start]
+ }
+ // OK, declare this very fuzzy match to be our new needle.
+ spec, count := Unique(haystack, needle, replace)
+ if count != 1 {
+ return nil, false
+ }
+ return spec, true
+}
+
+// UniqueInValidGo is Unique, but with flexibility around all leading and trailing whitespace.
+// Like UniqueDedent, it returns a boolean indicating whether a unique match was found.
+// It is safe (enough) because it ensures that the needle alterations occurs only in places
+// where whitespace is not semantically significant.
+// In practice, this appears safe.
+func UniqueInValidGo(haystack, needle, replace string) (*Spec, bool) {
+ haystackLines := slices.Collect(strings.Lines(haystack))
+ needleLines := slices.Collect(strings.Lines(needle))
+ matchStart := uniqueTrimmedLineMatch(haystackLines, needleLines)
+ if matchStart == -1 {
+ return nil, false
+ }
+ needle, replace = improveNeedle(haystack, needle, replace, matchStart)
+ matchEnd := matchStart + strings.Count(needle, "\n")
+ // Ensure that none of the lines that we fuzzy-matched involve a multiline comment or string literal.
+ var s scanner.Scanner
+ fset := token.NewFileSet()
+ file := fset.AddFile("", fset.Base(), len(haystack))
+ s.Init(file, []byte(haystack), nil, scanner.ScanComments)
+ for {
+ pos, tok, lit := s.Scan()
+ if s.ErrorCount > 0 {
+ return nil, false // invalid Go code (or not Go code at all)
+ }
+ if tok == token.EOF {
+ break
+ }
+ if tok == token.SEMICOLON || !strings.Contains(lit, "\n") {
+ continue
+ }
+ // In a token that spans multiple lines,
+ // so not perfectly matching whitespace might be unsafe.
+ p := fset.Position(pos)
+ tokenStart := p.Line - 1 // 1-based to 0-based
+ tokenEnd := tokenStart + strings.Count(lit, "\n")
+ // Check whether [matchStart, matchEnd] overlaps [tokenStart, tokenEnd]
+ // TODO: think more about edge conditions here. Any off-by-one errors?
+ // For example, leading whitespace and trailing whitespace
+ // on this token's lines are not semantically significant.
+ if tokenStart <= matchEnd && matchStart <= tokenEnd {
+ // if tokenStart <= matchStart && tokenEnd <= tokenEnd {}
+ return nil, false // this token overlaps the range we're replacing, not safe
+ }
+ }
+
+ // TODO: restore this sanity check? it's mildly expensive and i've never seen it fail.
+ // replaced := strings.Join(haystackLines[:matchStart], "") + replacement + strings.Join(haystackLines[matchEnd:], "")
+ // _, err := format.Source([]byte(replaced))
+ // if err != nil {
+ // return nil, false
+ // }
+
+ // OK, declare this very fuzzy match to be our new needle.
+ needle = strings.Join(haystackLines[matchStart:matchEnd], "")
+ spec, count := Unique(haystack, needle, replace)
+ if count != 1 {
+ return nil, false
+ }
+ return spec, true
+}
+
+// UniqueTrim is Unique, but with flexibility to shrink old/replace in tandem.
+func UniqueTrim(haystack, needle, replace string) (*Spec, bool) {
+ // LLMs appear to particularly struggle with the first line of a patch.
+ // If that first line is replicated in replace,
+ // and removing it yields a unique match,
+ // we can remove that line entirely from both.
+ n0, nRest, nOK := strings.Cut(needle, "\n")
+ r0, rRest, rOK := strings.Cut(replace, "\n")
+ if !nOK || !rOK || n0 != r0 {
+ return nil, false
+ }
+ spec, count := Unique(haystack, nRest, rRest)
+ if count != 1 {
+ return nil, false
+ }
+ return spec, true
+}
+
+// uniqueTrimmedLineMatch returns the index of the first line in haystack that matches needle,
+// when ignoring leading and trailing whitespace.
+// uniqueTrimmedLineMatch returns -1 if there is no unique match.
+func uniqueTrimmedLineMatch(haystackLines, needleLines []string) int {
+ // TODO: optimize
+ trimmedHaystackLines := trimSpaceAll(haystackLines)
+ trimmedNeedleLines := trimSpaceAll(needleLines)
+ match := -1
+ for i := range trimmedHaystackLines {
+ rest := trimmedHaystackLines[i:]
+ if len(rest) < len(trimmedNeedleLines) {
+ break
+ }
+ rest = rest[:len(trimmedNeedleLines)]
+ if !slices.Equal(rest, trimmedNeedleLines) {
+ continue
+ }
+ if match != -1 {
+ return -1 // multiple matches
+ }
+ match = i
+ }
+ return match
+}
+
+func trimSpaceAll(x []string) []string {
+ trimmed := make([]string, len(x))
+ for i, s := range x {
+ trimmed[i] = strings.TrimSpace(s)
+ }
+ return trimmed
+}
+
+// improveNeedle adjusts both needle and replacement in tandem to better match haystack.
+// Note that we adjust search and replace together.
+func improveNeedle(haystack, needle, replacement string, matchLine int) (string, string) {
+ // TODO: we make new slices too much
+ needleLines := slices.Collect(strings.Lines(needle))
+ if len(needleLines) == 0 {
+ return needle, replacement
+ }
+ haystackLines := slices.Collect(strings.Lines(haystack))
+ if matchLine+len(needleLines) > len(haystackLines) {
+ // should be impossible, but just in case
+ return needle, replacement
+ }
+ // Add trailing last-line newline if needed to better match haystack.
+ if !strings.HasSuffix(needle, "\n") && strings.HasSuffix(haystackLines[matchLine+len(needleLines)-1], "\n") {
+ needle += "\n"
+ replacement += "\n"
+ }
+ // Add leading first-line prefix if needed to better match haystack.
+ rest, ok := strings.CutSuffix(haystackLines[matchLine], needleLines[0])
+ if ok {
+ needle = rest + needle
+ replacement = rest + replacement
+ }
+ return needle, replacement
+}
+
+func isNonSpace(r rune) bool {
+ return !unicode.IsSpace(r)
+}
+
+func whitespacePrefix(s string) string {
+ firstNonSpace := strings.IndexFunc(s, isNonSpace)
+ return s[:max(0, firstNonSpace)] // map -1 for "not found" onto 0
+}
+
+// commonWhitespacePrefix returns the longest common whitespace prefix of the elements of x, somewhat flexibly.
+func commonWhitespacePrefix(x []string) string {
+ var pre string
+ for i, s := range x {
+ if i == 0 {
+ pre = s
+ continue
+ }
+ // ignore line endings for the moment
+ // (this is just for prefixes)
+ s = strings.TrimRight(s, "\n\r")
+ if s == "" {
+ continue
+ }
+ n := commonPrefixLen(pre, whitespacePrefix(s))
+ if n == 0 {
+ return ""
+ }
+ pre = pre[:n]
+ }
+ pre = strings.TrimRightFunc(pre, isNonSpace)
+ return pre
+}
+
+// commonPrefixLen returns the length of the common prefix of two strings.
+// TODO: optimize, see e.g. https://go-review.googlesource.com/c/go/+/408116
+func commonPrefixLen(a, b string) int {
+ shortest := min(len(a), len(b))
+ for i := range shortest {
+ if a[i] != b[i] {
+ return i
+ }
+ }
+ return shortest
+}
+
+// commonSuffixLen returns the length of the common suffix of two strings.
+// TODO: optimize
+func commonSuffixLen(a, b string) int {
+ shortest := min(len(a), len(b))
+ for i := 0; i < shortest; i++ {
+ if a[len(a)-i-1] != b[len(b)-i-1] {
+ return i
+ }
+ }
+ return shortest
+}
@@ -0,0 +1,572 @@
+package patchkit
+
+import (
+ "strings"
+ "testing"
+
+ "sketch.dev/claudetool/editbuf"
+)
+
+func TestUnique(t *testing.T) {
+ tests := []struct {
+ name string
+ haystack string
+ needle string
+ replace string
+ wantCount int
+ wantOff int
+ wantLen int
+ }{
+ {
+ name: "single_match",
+ haystack: "hello world hello",
+ needle: "world",
+ replace: "universe",
+ wantCount: 1,
+ wantOff: 6,
+ wantLen: 5,
+ },
+ {
+ name: "no_match",
+ haystack: "hello world",
+ needle: "missing",
+ replace: "found",
+ wantCount: 0,
+ },
+ {
+ name: "multiple_matches",
+ haystack: "hello hello hello",
+ needle: "hello",
+ replace: "hi",
+ wantCount: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, count := Unique(tt.haystack, tt.needle, tt.replace)
+ if count != tt.wantCount {
+ t.Errorf("Unique() count = %v, want %v", count, tt.wantCount)
+ }
+ if count == 1 {
+ if spec.Off != tt.wantOff {
+ t.Errorf("Unique() offset = %v, want %v", spec.Off, tt.wantOff)
+ }
+ if spec.Len != tt.wantLen {
+ t.Errorf("Unique() length = %v, want %v", spec.Len, tt.wantLen)
+ }
+ if spec.Old != tt.needle {
+ t.Errorf("Unique() old = %q, want %q", spec.Old, tt.needle)
+ }
+ if spec.New != tt.replace {
+ t.Errorf("Unique() new = %q, want %q", spec.New, tt.replace)
+ }
+ }
+ })
+ }
+}
+
+func TestSpec_ApplyToEditBuf(t *testing.T) {
+ haystack := "hello world hello"
+ spec, count := Unique(haystack, "world", "universe")
+ if count != 1 {
+ t.Fatalf("expected unique match, got count %d", count)
+ }
+
+ buf := editbuf.NewBuffer([]byte(haystack))
+ spec.ApplyToEditBuf(buf)
+
+ result, err := buf.Bytes()
+ if err != nil {
+ t.Fatalf("failed to get buffer bytes: %v", err)
+ }
+
+ expected := "hello universe hello"
+ if string(result) != expected {
+ t.Errorf("ApplyToEditBuf() = %q, want %q", string(result), expected)
+ }
+}
+
+func TestUniqueDedent(t *testing.T) {
+ tests := []struct {
+ name string
+ haystack string
+ needle string
+ replace string
+ wantOK bool
+ }{
+ {
+ name: "simple_case_that_should_work",
+ haystack: "hello\nworld",
+ needle: "hello\nworld",
+ replace: "hi\nthere",
+ wantOK: true,
+ },
+ {
+ name: "no_match",
+ haystack: "func test() {\n\treturn 1\n}",
+ needle: "func missing() {\n\treturn 2\n}",
+ replace: "func found() {\n\treturn 3\n}",
+ wantOK: false,
+ },
+ {
+ name: "multiple_matches",
+ haystack: "hello\nhello\n",
+ needle: "hello",
+ replace: "hi",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, ok := UniqueDedent(tt.haystack, tt.needle, tt.replace)
+ if ok != tt.wantOK {
+ t.Errorf("UniqueDedent() ok = %v, want %v", ok, tt.wantOK)
+ return
+ }
+ if ok {
+ // Test that it can be applied
+ buf := editbuf.NewBuffer([]byte(tt.haystack))
+ spec.ApplyToEditBuf(buf)
+ result, err := buf.Bytes()
+ if err != nil {
+ t.Errorf("failed to apply spec: %v", err)
+ }
+ // Just check that it changed something
+ if string(result) == tt.haystack {
+ t.Error("UniqueDedent produced no change")
+ }
+ }
+ })
+ }
+}
+
+func TestUniqueGoTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ haystack string
+ needle string
+ replace string
+ wantOK bool
+ }{
+ {
+ name: "basic_tokenization_works",
+ haystack: "a+b",
+ needle: "a+b",
+ replace: "a*b",
+ wantOK: true,
+ },
+ {
+ name: "invalid_go_code",
+ haystack: "not go code @#$",
+ needle: "@#$",
+ replace: "valid",
+ wantOK: false,
+ },
+ {
+ name: "needle_not_valid_go",
+ haystack: "func test() { return 1 }",
+ needle: "invalid @#$",
+ replace: "valid",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, ok := UniqueGoTokens(tt.haystack, tt.needle, tt.replace)
+ if ok != tt.wantOK {
+ t.Errorf("UniqueGoTokens() ok = %v, want %v", ok, tt.wantOK)
+ return
+ }
+ if ok {
+ // Test that it can be applied
+ buf := editbuf.NewBuffer([]byte(tt.haystack))
+ spec.ApplyToEditBuf(buf)
+ result, err := buf.Bytes()
+ if err != nil {
+ t.Errorf("failed to apply spec: %v", err)
+ }
+ // Check that replacement occurred
+ if !strings.Contains(string(result), tt.replace) {
+ t.Errorf("replacement not found in result: %q", string(result))
+ }
+ }
+ })
+ }
+}
+
+func TestUniqueInValidGo(t *testing.T) {
+ tests := []struct {
+ name string
+ haystack string
+ needle string
+ replace string
+ wantOK bool
+ }{
+ {
+ name: "leading_trailing_whitespace_difference",
+ haystack: `package main
+
+func test() {
+ if condition {
+ fmt.Println("hello")
+ }
+}`,
+ needle: `if condition {
+ fmt.Println("hello")
+ }`,
+ replace: `if condition {
+ fmt.Println("modified")
+ }`,
+ wantOK: true,
+ },
+ {
+ name: "invalid_go_haystack",
+ haystack: "not go code",
+ needle: "not",
+ replace: "valid",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, ok := UniqueInValidGo(tt.haystack, tt.needle, tt.replace)
+ if ok != tt.wantOK {
+ t.Errorf("UniqueInValidGo() ok = %v, want %v", ok, tt.wantOK)
+ return
+ }
+ if ok {
+ // Test that it can be applied
+ buf := editbuf.NewBuffer([]byte(tt.haystack))
+ spec.ApplyToEditBuf(buf)
+ result, err := buf.Bytes()
+ if err != nil {
+ t.Errorf("failed to apply spec: %v", err)
+ }
+ // Check that replacement occurred
+ if !strings.Contains(string(result), "modified") {
+ t.Errorf("expected replacement not found in result: %q", string(result))
+ }
+ }
+ })
+ }
+}
+
+func TestUniqueTrim(t *testing.T) {
+ tests := []struct {
+ name string
+ haystack string
+ needle string
+ replace string
+ wantOK bool
+ }{
+ {
+ name: "trim_first_line",
+ haystack: "line1\nline2\nline3",
+ needle: "line1\nline2",
+ replace: "line1\nmodified",
+ wantOK: true,
+ },
+ {
+ name: "different_first_lines",
+ haystack: "line1\nline2\nline3",
+ needle: "different\nline2",
+ replace: "different\nmodified",
+ wantOK: true, // Update: seems UniqueTrim is more flexible than expected
+ },
+ {
+ name: "no_newlines",
+ haystack: "single line",
+ needle: "single",
+ replace: "modified",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec, ok := UniqueTrim(tt.haystack, tt.needle, tt.replace)
+ if ok != tt.wantOK {
+ t.Errorf("UniqueTrim() ok = %v, want %v", ok, tt.wantOK)
+ return
+ }
+ if ok {
+ // Test that it can be applied
+ buf := editbuf.NewBuffer([]byte(tt.haystack))
+ spec.ApplyToEditBuf(buf)
+ result, err := buf.Bytes()
+ if err != nil {
+ t.Errorf("failed to apply spec: %v", err)
+ }
+ // Check that something changed
+ if string(result) == tt.haystack {
+ t.Error("UniqueTrim produced no change")
+ }
+ }
+ })
+ }
+}
+
+func TestCommonPrefixLen(t *testing.T) {
+ tests := []struct {
+ a, b string
+ want int
+ }{
+ {"hello", "help", 3},
+ {"abc", "xyz", 0},
+ {"same", "same", 4},
+ {"", "anything", 0},
+ {"a", "", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
+ got := commonPrefixLen(tt.a, tt.b)
+ if got != tt.want {
+ t.Errorf("commonPrefixLen(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCommonSuffixLen(t *testing.T) {
+ tests := []struct {
+ a, b string
+ want int
+ }{
+ {"hello", "jello", 4},
+ {"abc", "xyz", 0},
+ {"same", "same", 4},
+ {"", "anything", 0},
+ {"a", "", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
+ got := commonSuffixLen(tt.a, tt.b)
+ if got != tt.want {
+ t.Errorf("commonSuffixLen(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSpec_minimize(t *testing.T) {
+ tests := []struct {
+ name string
+ old, new string
+ wantOff int
+ wantLen int
+ wantOld string
+ wantNew string
+ }{
+ {
+ name: "common_prefix_suffix",
+ old: "prefixMIDDLEsuffix",
+ new: "prefixCHANGEDsuffix",
+ wantOff: 6,
+ wantLen: 6,
+ wantOld: "MIDDLE",
+ wantNew: "CHANGED",
+ },
+ {
+ name: "no_common_parts",
+ old: "abc",
+ new: "xyz",
+ wantOff: 0,
+ wantLen: 3,
+ wantOld: "abc",
+ wantNew: "xyz",
+ },
+ {
+ name: "identical_strings",
+ old: "same",
+ new: "same",
+ wantOff: 4,
+ wantLen: 0,
+ wantOld: "",
+ wantNew: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec := &Spec{
+ Off: 0,
+ Len: len(tt.old),
+ Old: tt.old,
+ New: tt.new,
+ }
+ spec.minimize()
+
+ if spec.Off != tt.wantOff {
+ t.Errorf("minimize() Off = %v, want %v", spec.Off, tt.wantOff)
+ }
+ if spec.Len != tt.wantLen {
+ t.Errorf("minimize() Len = %v, want %v", spec.Len, tt.wantLen)
+ }
+ if spec.Old != tt.wantOld {
+ t.Errorf("minimize() Old = %q, want %q", spec.Old, tt.wantOld)
+ }
+ if spec.New != tt.wantNew {
+ t.Errorf("minimize() New = %q, want %q", spec.New, tt.wantNew)
+ }
+ })
+ }
+}
+
+func TestWhitespacePrefix(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {" hello", " "},
+ {"\t\tworld", "\t\t"},
+ {"no_prefix", ""},
+ {" \n", ""}, // whitespacePrefix stops at first non-space
+ {"", ""},
+ {" ", ""}, // whitespace-only string treated as having no prefix
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := whitespacePrefix(tt.input)
+ if got != tt.want {
+ t.Errorf("whitespacePrefix(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCommonWhitespacePrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ lines []string
+ want string
+ }{
+ {
+ name: "common_spaces",
+ lines: []string{" hello", " world", " test"},
+ want: " ",
+ },
+ {
+ name: "mixed_indentation",
+ lines: []string{"\t\thello", "\tworld"},
+ want: "\t",
+ },
+ {
+ name: "no_common_prefix",
+ lines: []string{"hello", " world"},
+ want: "",
+ },
+ {
+ name: "empty_lines_ignored",
+ lines: []string{" hello", "", " world"},
+ want: " ",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := commonWhitespacePrefix(tt.lines)
+ if got != tt.want {
+ t.Errorf("commonWhitespacePrefix() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTokenize(t *testing.T) {
+ tests := []struct {
+ name string
+ code string
+ wantOK bool
+ expected []string // token representations for verification
+ }{
+ {
+ name: "simple_go_code",
+ code: "func main() { fmt.Println(\"hello\") }",
+ wantOK: true,
+ expected: []string{"func(\"func\")", "IDENT(\"main\")", "(", ")", "{", "IDENT(\"fmt\")", ".", "IDENT(\"Println\")", "(", "STRING(\"\\\"hello\\\"\")", ")", "}", ";(\"\\n\")"},
+ },
+ {
+ name: "invalid_code",
+ code: "@#$%invalid",
+ wantOK: false,
+ },
+ {
+ name: "empty_code",
+ code: "",
+ wantOK: true,
+ expected: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tokens, ok := tokenize(tt.code)
+ if ok != tt.wantOK {
+ t.Errorf("tokenize() ok = %v, want %v", ok, tt.wantOK)
+ return
+ }
+ if ok && len(tt.expected) > 0 {
+ if len(tokens) != len(tt.expected) {
+ t.Errorf("tokenize() produced %d tokens, want %d", len(tokens), len(tt.expected))
+ return
+ }
+ for i, expected := range tt.expected {
+ if tokens[i].String() != expected {
+ t.Errorf("token[%d] = %s, want %s", i, tokens[i].String(), expected)
+ }
+ }
+ }
+ })
+ }
+}
+
+// Benchmark the core Unique function
+func BenchmarkUnique(b *testing.B) {
+ haystack := strings.Repeat("hello world ", 1000) + "TARGET" + strings.Repeat(" goodbye world", 1000)
+ needle := "TARGET"
+ replace := "REPLACEMENT"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, count := Unique(haystack, needle, replace)
+ if count != 1 {
+ b.Fatalf("expected unique match, got %d", count)
+ }
+ }
+}
+
+// Benchmark fuzzy matching functions
+func BenchmarkUniqueDedent(b *testing.B) {
+ haystack := "hello\nworld"
+ needle := "hello\nworld"
+ replace := "hi\nthere"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, ok := UniqueDedent(haystack, needle, replace)
+ if !ok {
+ b.Fatal("expected successful match")
+ }
+ }
+}
+
+func BenchmarkUniqueGoTokens(b *testing.B) {
+ haystack := "a+b"
+ needle := "a+b"
+ replace := "a*b"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, ok := UniqueGoTokens(haystack, needle, replace)
+ if !ok {
+ b.Fatal("expected successful match")
+ }
+ }
+}
@@ -0,0 +1,38 @@
+// Package claudetool provides tools for Claude AI models.
+//
+// When adding, removing, or modifying tools in this package,
+// remember to update the tool display template in termui/termui.go
+// to ensure proper tool output formatting.
+package claudetool
+
+import (
+ "context"
+)
+
+type workingDirCtxKeyType string
+
+const workingDirCtxKey workingDirCtxKeyType = "workingDir"
+
+func WithWorkingDir(ctx context.Context, wd string) context.Context {
+ return context.WithValue(ctx, workingDirCtxKey, wd)
+}
+
+func WorkingDir(ctx context.Context) string {
+ // If cmd.Dir is empty, it uses the current working directory,
+ // so we can use that as a fallback.
+ wd, _ := ctx.Value(workingDirCtxKey).(string)
+ return wd
+}
+
+type sessionIDCtxKeyType string
+
+const sessionIDCtxKey sessionIDCtxKeyType = "sessionID"
+
+func WithSessionID(ctx context.Context, sessionID string) context.Context {
+ return context.WithValue(ctx, sessionIDCtxKey, sessionID)
+}
+
+func SessionID(ctx context.Context) string {
+ sessionID, _ := ctx.Value(sessionIDCtxKey).(string)
+ return sessionID
+}
@@ -0,0 +1,39 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+
+ "shelley.exe.dev/llm"
+)
+
+// The Think tool provides space to think.
+var Think = &llm.Tool{
+ Name: thinkName,
+ Description: thinkDescription,
+ InputSchema: llm.MustSchema(thinkInputSchema),
+ Run: thinkRun,
+}
+
+const (
+ thinkName = "think"
+ thinkDescription = `Think out loud, take notes, form plans. Has no external effects.`
+
+ // If you modify this, update the termui template for prettier rendering.
+ thinkInputSchema = `
+{
+ "type": "object",
+ "required": ["thoughts"],
+ "properties": {
+ "thoughts": {
+ "type": "string",
+ "description": "The thoughts, notes, or plans to record"
+ }
+ }
+}
+`
+)
+
+func thinkRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ return llm.ToolOut{LLMContent: llm.TextContent("recorded")}
+}
@@ -0,0 +1,137 @@
+package claudetool
+
+import (
+ "context"
+ "strings"
+ "sync"
+
+ "shelley.exe.dev/claudetool/browse"
+ "shelley.exe.dev/llm"
+)
+
+// WorkingDir is a thread-safe mutable working directory.
+type MutableWorkingDir struct {
+ mu sync.RWMutex
+ dir string
+}
+
+// NewMutableWorkingDir creates a new MutableWorkingDir with the given initial directory.
+func NewMutableWorkingDir(dir string) *MutableWorkingDir {
+ return &MutableWorkingDir{dir: dir}
+}
+
+// Get returns the current working directory.
+func (w *MutableWorkingDir) Get() string {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.dir
+}
+
+// Set updates the working directory.
+func (w *MutableWorkingDir) Set(dir string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.dir = dir
+}
+
+// ToolSetConfig contains configuration for creating a ToolSet.
+type ToolSetConfig struct {
+ // WorkingDir is the initial working directory for tools.
+ WorkingDir string
+ // LLMProvider provides access to LLM services for tool validation.
+ LLMProvider LLMServiceProvider
+ // EnableJITInstall enables just-in-time tool installation.
+ EnableJITInstall bool
+ // EnableBrowser enables browser tools.
+ EnableBrowser bool
+ // ModelID is the model being used for this conversation.
+ // Used to determine tool configuration (e.g., simplified patch schema for weaker models).
+ ModelID string
+ // OnWorkingDirChange is called when the working directory changes.
+ // This can be used to persist the change to a database.
+ OnWorkingDirChange func(newDir string)
+}
+
+// ToolSet holds a set of tools for a single conversation.
+// Each conversation should have its own ToolSet.
+type ToolSet struct {
+ tools []*llm.Tool
+ cleanup func()
+ wd *MutableWorkingDir
+}
+
+// Tools returns the tools in this set.
+func (ts *ToolSet) Tools() []*llm.Tool {
+ return ts.tools
+}
+
+// Cleanup releases resources held by the tools (e.g., browser).
+func (ts *ToolSet) Cleanup() {
+ if ts.cleanup != nil {
+ ts.cleanup()
+ }
+}
+
+// WorkingDir returns the shared working directory.
+func (ts *ToolSet) WorkingDir() *MutableWorkingDir {
+ return ts.wd
+}
+
+// NewToolSet creates a new set of tools for a conversation.
+// isStrongModel returns true for models that can handle complex tool schemas.
+func isStrongModel(modelID string) bool {
+ lower := strings.ToLower(modelID)
+ return strings.Contains(lower, "sonnet") || strings.Contains(lower, "opus")
+}
+
+func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
+ workingDir := cfg.WorkingDir
+ if workingDir == "" {
+ workingDir = "/"
+ }
+ wd := NewMutableWorkingDir(workingDir)
+
+ bashTool := &BashTool{
+ WorkingDir: wd,
+ LLMProvider: cfg.LLMProvider,
+ EnableJITInstall: cfg.EnableJITInstall,
+ }
+
+ // Use simplified patch schema for weaker models, full schema for sonnet/opus
+ simplified := !isStrongModel(cfg.ModelID)
+ patchTool := &PatchTool{
+ Simplified: simplified,
+ WorkingDir: wd,
+ ClipboardEnabled: true,
+ }
+
+ keywordTool := NewKeywordToolWithWorkingDir(cfg.LLMProvider, wd)
+
+ changeDirTool := &ChangeDirTool{
+ WorkingDir: wd,
+ OnChange: cfg.OnWorkingDirChange,
+ }
+
+ tools := []*llm.Tool{
+ Think,
+ bashTool.Tool(),
+ patchTool.Tool(),
+ keywordTool.Tool(),
+ changeDirTool.Tool(),
+ }
+
+ var cleanup func()
+ if cfg.EnableBrowser {
+ browserTools, browserCleanup := browse.RegisterBrowserTools(ctx, true)
+ if len(browserTools) > 0 {
+ tools = append(tools, browserTools...)
+ }
+ cleanup = browserCleanup
+ }
+
+ return &ToolSet{
+ tools: tools,
+ cleanup: cleanup,
+ wd: wd,
+ }
+}
@@ -0,0 +1,93 @@
+// A command line tool for generating typescript type declarations from go
+// struct types.
+//
+// Example:
+//
+// go run ./cmd/go2ts -o ui/src/generated-types.ts
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "go.skia.org/infra/go/go2ts"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+)
+
+func main() {
+ outputPath := flag.String("o", "", "Path to the output TypeScript file.")
+ flag.Parse()
+
+ if *outputPath == "" {
+ fmt.Println("Usage: go run ./cmd/go2ts -o <output-file>")
+ os.Exit(1)
+ }
+
+ generator := TS()
+
+ w, err := os.Create(*outputPath)
+ if err != nil {
+ fmt.Printf("error: %v\n", err)
+ os.Exit(1)
+ }
+ defer w.Close()
+
+ fmt.Fprintf(w, "// Auto-generated by shelley.exe.dev/cmd/go2ts.go\n")
+ fmt.Fprintf(w, "// Do not edit manually - regenerate with: go run ./cmd/go2ts -o ui/src/generated-types.ts\n\n")
+ generator.Render(w)
+}
+
+// TS returns a Go2TS generator for go types we want to use in TypeScript.
+func TS() *go2ts.Go2TS {
+ generator := go2ts.New()
+
+ // Database message types enum
+ generator.AddMultipleUnion(
+ []db.MessageType{
+ db.MessageTypeUser,
+ db.MessageTypeAgent,
+ db.MessageTypeTool,
+ db.MessageTypeError,
+ db.MessageTypeSystem,
+ },
+ )
+
+ // Database struct types
+ generator.AddMultiple(
+ generated.Conversation{},
+ llm.Usage{},
+ )
+
+ generator.AddMultiple(
+ apiMessageForTS{},
+ streamResponseForTS{},
+ )
+
+ // Generate clean nominal types
+ generator.GenerateNominalTypes = true
+
+ return generator
+}
+
+type apiMessageForTS struct {
+ MessageID string `json:"message_id"`
+ ConversationID string `json:"conversation_id"`
+ SequenceID int64 `json:"sequence_id"`
+ Type string `json:"type"`
+ LlmData *string `json:"llm_data,omitempty"`
+ UserData *string `json:"user_data,omitempty"`
+ UsageData *string `json:"usage_data,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ DisplayData *string `json:"display_data,omitempty"`
+ EndOfTurn *bool `json:"end_of_turn,omitempty"`
+}
+
+type streamResponseForTS struct {
+ Messages []apiMessageForTS `json:"messages"`
+ Conversation generated.Conversation `json:"conversation"`
+ AgentWorking bool `json:"agent_working"`
+}
@@ -0,0 +1,376 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/models"
+ "shelley.exe.dev/server"
+ "shelley.exe.dev/templates"
+ "shelley.exe.dev/version"
+)
+
+type GlobalConfig struct {
+ DBPath string
+ Debug bool
+ Model string
+ PredictableOnly bool
+ ConfigPath string
+ TerminalURL string
+ DefaultModel string
+}
+
+func main() {
+ // Define global flags
+ var global GlobalConfig
+ defaultModelID := models.Default().ID
+ flag.StringVar(&global.DBPath, "db", "shelley.db", "Path to SQLite database file")
+ flag.BoolVar(&global.Debug, "debug", false, "Enable debug logging")
+ flag.StringVar(&global.Model, "model", defaultModelID, "LLM model to use (use 'predictable' for testing)")
+ flag.BoolVar(&global.PredictableOnly, "predictable-only", false, "Use only the predictable service, ignoring all other models")
+ flag.StringVar(&global.ConfigPath, "config", "", "Path to shelley.json configuration file (optional)")
+ flag.StringVar(&global.DefaultModel, "default-model", defaultModelID, "Default model for web UI")
+
+ // Custom usage function
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [global-flags] <command> [command-flags]\n\n", os.Args[0])
+ fmt.Fprintf(flag.CommandLine.Output(), "Global flags:\n")
+ flag.PrintDefaults()
+ fmt.Fprintf(flag.CommandLine.Output(), "\nCommands:\n")
+ fmt.Fprintf(flag.CommandLine.Output(), " serve [flags] Start the web server\n")
+ fmt.Fprintf(flag.CommandLine.Output(), " unpack-template <name> <dir> Unpack a project template to a directory\n")
+ fmt.Fprintf(flag.CommandLine.Output(), " version Print version information as JSON\n")
+ fmt.Fprintf(flag.CommandLine.Output(), "\nUse '%s <command> -h' for command-specific help\n", os.Args[0])
+ }
+
+ // Parse all flags first
+ flag.Parse()
+ args := flag.Args()
+
+ // Apply seccomp filter early, before spawning any child processes.
+ // This prevents child processes from killing shelley.
+ // Turns out this doesn't work, because it blocks sudo, which we want to work.
+ // if err := seccomp.BlockKillSelf(); err != nil {
+ // slog.Info("seccomp filter not installed", "error", err)
+ // }
+
+ if len(args) == 0 {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ command := args[0]
+ switch command {
+ case "serve":
+ runServe(global, args[1:])
+ case "unpack-template":
+ runUnpackTemplate(args[1:])
+ case "version":
+ runVersion()
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
+ flag.Usage()
+ os.Exit(1)
+ }
+}
+
+func runServe(global GlobalConfig, args []string) {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ port := fs.String("port", "9000", "Port to listen on")
+ systemdActivation := fs.Bool("systemd-activation", false, "Use systemd socket activation (listen on fd from systemd)")
+ requireHeader := fs.String("require-header", "", "Require this header on all API requests (e.g., X-Exedev-Userid)")
+ fs.Parse(args)
+
+ logger := setupLogging(global.Debug)
+
+ database := setupDatabase(global.DBPath, logger)
+ defer database.Close()
+
+ // Set the database path for system prompt generation
+ server.DBPath = global.DBPath
+
+ // Build LLM configuration
+ llmConfig := buildLLMConfig(logger, global.ConfigPath, global.TerminalURL, global.DefaultModel)
+
+ // Create request history for debugging
+ llmHistory := models.NewLLMRequestHistory(10)
+
+ // Initialize LLM service manager
+ llmManager := server.NewLLMServiceManager(llmConfig, llmHistory)
+
+ // Log available models
+ availableModels := llmManager.GetAvailableModels()
+ logger.Info("Available models", "models", strings.Join(availableModels, ", "))
+
+ toolSetConfig := setupToolSetConfig(llmManager)
+
+ // Create server
+ svr := server.NewServer(database, llmManager, toolSetConfig, logger, global.PredictableOnly, llmConfig.TerminalURL, llmConfig.DefaultModel, *requireHeader, llmConfig.Links)
+
+ var err error
+ if *systemdActivation {
+ listener, listenerErr := systemdListener()
+ if listenerErr != nil {
+ logger.Error("Failed to get systemd listener", "error", listenerErr)
+ os.Exit(1)
+ }
+ logger.Info("Using systemd socket activation")
+ err = svr.StartWithListener(listener)
+ } else {
+ err = svr.Start(*port)
+ }
+
+ if err != nil {
+ logger.Error("Server failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+func setupLogging(debug bool) *slog.Logger {
+ logLevel := slog.LevelInfo
+ if debug {
+ logLevel = slog.LevelDebug
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: logLevel,
+ }))
+ slog.SetDefault(logger)
+ return logger
+}
+
+func setupDatabase(dbPath string, logger *slog.Logger) *db.DB {
+ database, err := db.New(db.Config{DSN: dbPath})
+ if err != nil {
+ logger.Error("Failed to initialize database", "error", err)
+ os.Exit(1)
+ }
+
+ // Run database migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ logger.Error("Failed to run database migrations", "error", err)
+ os.Exit(1)
+ }
+ logger.Debug("Database migrations completed successfully")
+ return database
+}
+
+// runUnpackTemplate unpacks a project template to a directory
+func runUnpackTemplate(args []string) {
+ fs := flag.NewFlagSet("unpack-template", flag.ExitOnError)
+ fs.Usage = func() {
+ fmt.Fprintf(fs.Output(), "Usage: shelley unpack-template <template-name> <directory>\n\n")
+ fmt.Fprintf(fs.Output(), "Unpacks a project template to the specified directory.\n\n")
+ fmt.Fprintf(fs.Output(), "Available templates:\n")
+ names, err := templates.List()
+ if err != nil {
+ fmt.Fprintf(fs.Output(), " (error listing templates: %v)\n", err)
+ } else if len(names) == 0 {
+ fmt.Fprintf(fs.Output(), " (no templates available)\n")
+ } else {
+ for _, name := range names {
+ fmt.Fprintf(fs.Output(), " %s\n", name)
+ }
+ }
+ }
+ fs.Parse(args)
+
+ if fs.NArg() < 2 {
+ fs.Usage()
+ os.Exit(1)
+ }
+
+ templateName := fs.Arg(0)
+ destDir := fs.Arg(1)
+
+ // Verify template exists
+ names, err := templates.List()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error listing templates: %v\n", err)
+ os.Exit(1)
+ }
+ found := false
+ for _, name := range names {
+ if name == templateName {
+ found = true
+ break
+ }
+ }
+ if !found {
+ fmt.Fprintf(os.Stderr, "Error: template %q not found\n", templateName)
+ fmt.Fprintf(os.Stderr, "Available templates: %s\n", strings.Join(names, ", "))
+ os.Exit(1)
+ }
+
+ // Create destination directory if it doesn't exist
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating directory %q: %v\n", destDir, err)
+ os.Exit(1)
+ }
+
+ // Unpack the template
+ if err := templates.Unpack(templateName, destDir); err != nil {
+ fmt.Fprintf(os.Stderr, "Error unpacking template: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Template %q unpacked to %s\n", templateName, destDir)
+}
+
+// runVersion prints version information as JSON
+func runVersion() {
+ info := version.GetInfo()
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(info); err != nil {
+ fmt.Fprintf(os.Stderr, "Error encoding version: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func setupToolSetConfig(llmProvider claudetool.LLMServiceProvider) claudetool.ToolSetConfig {
+ wd, err := os.Getwd()
+ if err != nil {
+ // Fallback to "/" if we can't get working directory
+ wd = "/"
+ }
+ return claudetool.ToolSetConfig{
+ WorkingDir: wd,
+ LLMProvider: llmProvider,
+ EnableJITInstall: claudetool.EnableBashToolJITInstall,
+ EnableBrowser: true,
+ }
+}
+
+// buildLLMConfig constructs LLMConfig from environment variables and optional config file
+func buildLLMConfig(logger *slog.Logger, configPath, terminalURL, defaultModel string) *server.LLMConfig {
+ llmCfg := &server.LLMConfig{
+ AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
+ OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
+ GeminiAPIKey: os.Getenv("GEMINI_API_KEY"),
+ FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
+ TerminalURL: terminalURL,
+ DefaultModel: defaultModel,
+ Logger: logger,
+ }
+
+ if configPath != "" {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ logger.Warn("Failed to read config file", "path", configPath, "error", err)
+ }
+ return llmCfg
+ }
+
+ var cfg struct {
+ LLMGateway string `json:"llm_gateway"`
+ TerminalURL string `json:"terminal_url"`
+ DefaultModel string `json:"default_model"`
+ Links []server.Link `json:"links"`
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ logger.Warn("Failed to parse config file", "path", configPath, "error", err)
+ return llmCfg
+ }
+
+ if cfg.LLMGateway != "" {
+ gateway := strings.TrimSuffix(cfg.LLMGateway, "/")
+ llmCfg.Gateway = gateway
+ logger.Info("Using LLM gateway", "gateway", gateway)
+
+ // When using a gateway, default all API keys to "implicit" unless otherwise set
+ if llmCfg.AnthropicAPIKey == "" {
+ llmCfg.AnthropicAPIKey = "implicit"
+ }
+ if llmCfg.OpenAIAPIKey == "" {
+ llmCfg.OpenAIAPIKey = "implicit"
+ }
+ if llmCfg.GeminiAPIKey == "" {
+ llmCfg.GeminiAPIKey = "implicit"
+ }
+ if llmCfg.FireworksAPIKey == "" {
+ llmCfg.FireworksAPIKey = "implicit"
+ }
+ }
+
+ // Override terminal URL from config file if present and not already set via flag
+ if cfg.TerminalURL != "" && llmCfg.TerminalURL == "" {
+ llmCfg.TerminalURL = cfg.TerminalURL
+ logger.Info("Using terminal URL from config", "url", cfg.TerminalURL)
+ }
+
+ // Override default model from config file if present and not already set via flag
+ if cfg.DefaultModel != "" && llmCfg.DefaultModel == "" {
+ llmCfg.DefaultModel = cfg.DefaultModel
+ logger.Info("Using default model from config", "model", cfg.DefaultModel)
+ }
+
+ // Load links from config file if present
+ if len(cfg.Links) > 0 {
+ llmCfg.Links = cfg.Links
+ logger.Info("Loaded links from config", "count", len(cfg.Links))
+ }
+ }
+
+ return llmCfg
+}
+
+// systemdListener returns a net.Listener from systemd socket activation.
+// Systemd passes file descriptors starting at fd 3, with LISTEN_FDS indicating the count.
+func systemdListener() (net.Listener, error) {
+ // Check LISTEN_PID matches our PID (optional but recommended)
+ pidStr := os.Getenv("LISTEN_PID")
+ if pidStr != "" {
+ pid, err := strconv.Atoi(pidStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid LISTEN_PID: %w", err)
+ }
+ if pid != os.Getpid() {
+ return nil, fmt.Errorf("LISTEN_PID %d does not match current PID %d", pid, os.Getpid())
+ }
+ }
+
+ // Get the number of file descriptors passed
+ fdsStr := os.Getenv("LISTEN_FDS")
+ if fdsStr == "" {
+ return nil, fmt.Errorf("LISTEN_FDS not set; not running under systemd socket activation")
+ }
+ nfds, err := strconv.Atoi(fdsStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid LISTEN_FDS: %w", err)
+ }
+ if nfds < 1 {
+ return nil, fmt.Errorf("LISTEN_FDS=%d; expected at least 1", nfds)
+ }
+
+ // Systemd passes file descriptors starting at fd 3
+ const listenFDsStart = 3
+ fd := listenFDsStart
+
+ // Create a file from the descriptor
+ f := os.NewFile(uintptr(fd), "systemd-socket")
+ if f == nil {
+ return nil, fmt.Errorf("failed to create file from fd %d", fd)
+ }
+
+ // Create a listener from the file
+ listener, err := net.FileListener(f)
+ if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("failed to create listener from fd %d: %w", fd, err)
+ }
+
+ // Close the original file; the listener now owns the descriptor
+ f.Close()
+
+ return listener, nil
+}
@@ -0,0 +1,234 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/slug"
+)
+
+func TestSanitizeSlug(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"Simple Test", "simple-test"},
+ {"Create a Python Script", "create-a-python-script"},
+ {"Multiple Spaces", "multiple-spaces"},
+ {"Special@#$%Characters", "specialcharacters"},
+ {"Under_Score_Test", "under-score-test"},
+ {"--multiple-hyphens--", "multiple-hyphens"},
+ {"CamelCase Example", "camelcase-example"},
+ {"123 Numbers Test 456", "123-numbers-test-456"},
+ {" leading and trailing ", "leading-and-trailing"},
+ {"", ""},
+ }
+
+ for _, test := range tests {
+ result := slug.Sanitize(test.input)
+ if result != test.expected {
+ t.Errorf("slug.Sanitize(%q) = %q, expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestCLICommands(t *testing.T) {
+ // Build the binary once for this test and its subtests
+ tempDir := t.TempDir()
+ binary := filepath.Join(tempDir, "shelley")
+ cmd := exec.Command("go", "build", "-o", binary, ".")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to build binary: %v", err)
+ }
+
+ t.Run("help message", func(t *testing.T) {
+ cmd := exec.Command(binary)
+ output, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatal("Expected command to fail with no arguments")
+ }
+ outputStr := string(output)
+ if !strings.Contains(outputStr, "Commands:") {
+ t.Errorf("Expected help message, got: %s", outputStr)
+ }
+ })
+
+ t.Run("serve flag parsing", func(t *testing.T) {
+ // Test that serve command accepts flags - we can't easily test the full server
+ // but we can test that it doesn't immediately error on flag parsing
+ cmd := exec.Command(binary, "serve", "-h")
+ output, err := cmd.CombinedOutput()
+ // With flag package, -h should cause exit with code 2
+ if err != nil {
+ if exitError, ok := err.(*exec.ExitError); ok {
+ if exitError.ExitCode() == 2 {
+ // This is expected for -h flag
+ outputStr := string(output)
+ if !strings.Contains(outputStr, "-port") || !strings.Contains(outputStr, "-db") {
+ t.Errorf("Expected serve help to show -port and -db flags, got: %s", outputStr)
+ }
+ if !strings.Contains(outputStr, "-systemd-activation") {
+ t.Errorf("Expected serve help to show -systemd-activation flag, got: %s", outputStr)
+ }
+ return
+ }
+ }
+ }
+ // If no error or different error, that's also fine for this basic test
+ t.Logf("Serve command output: %s", string(output))
+ })
+}
+
+func TestSystemdListenerErrors(t *testing.T) {
+ // Save original environment
+ origPID := os.Getenv("LISTEN_PID")
+ origFDs := os.Getenv("LISTEN_FDS")
+ defer func() {
+ os.Setenv("LISTEN_PID", origPID)
+ os.Setenv("LISTEN_FDS", origFDs)
+ }()
+
+ t.Run("no LISTEN_FDS", func(t *testing.T) {
+ os.Unsetenv("LISTEN_FDS")
+ os.Unsetenv("LISTEN_PID")
+ _, err := systemdListener()
+ if err == nil {
+ t.Fatal("Expected error when LISTEN_FDS not set")
+ }
+ if !strings.Contains(err.Error(), "LISTEN_FDS not set") {
+ t.Errorf("Unexpected error message: %v", err)
+ }
+ })
+
+ t.Run("wrong LISTEN_PID", func(t *testing.T) {
+ os.Setenv("LISTEN_FDS", "1")
+ os.Setenv("LISTEN_PID", "99999999") // Unlikely to match our PID
+ _, err := systemdListener()
+ if err == nil {
+ t.Fatal("Expected error when LISTEN_PID doesn't match")
+ }
+ if !strings.Contains(err.Error(), "does not match current PID") {
+ t.Errorf("Unexpected error message: %v", err)
+ }
+ })
+
+ t.Run("invalid LISTEN_FDS", func(t *testing.T) {
+ os.Setenv("LISTEN_FDS", "notanumber")
+ os.Unsetenv("LISTEN_PID")
+ _, err := systemdListener()
+ if err == nil {
+ t.Fatal("Expected error when LISTEN_FDS is invalid")
+ }
+ if !strings.Contains(err.Error(), "invalid LISTEN_FDS") {
+ t.Errorf("Unexpected error message: %v", err)
+ }
+ })
+
+ t.Run("zero LISTEN_FDS", func(t *testing.T) {
+ os.Setenv("LISTEN_FDS", "0")
+ os.Unsetenv("LISTEN_PID")
+ _, err := systemdListener()
+ if err == nil {
+ t.Fatal("Expected error when LISTEN_FDS is 0")
+ }
+ if !strings.Contains(err.Error(), "expected at least 1") {
+ t.Errorf("Unexpected error message: %v", err)
+ }
+ })
+}
+
+func TestSystemdListenerIntegration(t *testing.T) {
+ // This test simulates what systemd does: create a listener, get the fd,
+ // and pass it to a child process via environment and fd inheritance.
+ // Since we can't easily test in-process (fd 3 is likely already in use),
+ // we test by spawning a subprocess.
+
+ tempDir := t.TempDir()
+ binary := filepath.Join(tempDir, "shelley")
+ cmd := exec.Command("go", "build", "-o", binary, ".")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Failed to build binary: %v", err)
+ }
+
+ // Create a listener on a random port
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("Failed to create listener: %v", err)
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+
+ // Get the file descriptor from the listener
+ tcpListener := listener.(*net.TCPListener)
+ file, err := tcpListener.File()
+ if err != nil {
+ listener.Close()
+ t.Fatalf("Failed to get file from listener: %v", err)
+ }
+ listener.Close() // Close original listener, file still holds the socket
+
+ // Create a temp database for the test
+ dbPath := filepath.Join(tempDir, "test.db")
+
+ // Spawn shelley with the file descriptor as fd 3
+ // Note: We don't set LISTEN_PID here because we don't know the child PID yet.
+ // The systemdListener function handles missing LISTEN_PID gracefully.
+ cmd = exec.Command(binary, "-db", dbPath, "serve", "-systemd-activation")
+ // Build environment without LISTEN_PID (will be inherited from parent otherwise)
+ // and add LISTEN_FDS=1
+ env := make([]string, 0, len(os.Environ()))
+ for _, e := range os.Environ() {
+ if !strings.HasPrefix(e, "LISTEN_PID=") {
+ env = append(env, e)
+ }
+ }
+ env = append(env, "LISTEN_FDS=1")
+ cmd.Env = env
+ cmd.ExtraFiles = []*os.File{file} // This makes the file fd 3 in the child
+ var stderrBuf, stdoutBuf bytes.Buffer
+ cmd.Stdout = &stdoutBuf
+ cmd.Stderr = &stderrBuf
+
+ // Start the process
+ if err := cmd.Start(); err != nil {
+ file.Close()
+ t.Fatalf("Failed to start shelley: %v", err)
+ }
+ file.Close() // Close our copy after child inherits it
+
+ // Wait a bit for the server to start
+ time.Sleep(500 * time.Millisecond)
+
+ // Try to connect to the server
+ var resp *http.Response
+ client := &http.Client{Timeout: 2 * time.Second}
+ for i := 0; i < 10; i++ {
+ resp, err = client.Get(fmt.Sprintf("http://127.0.0.1:%d/version", port))
+ if err == nil {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ // Kill the server
+ cmd.Process.Kill()
+ cmd.Wait()
+
+ if err != nil {
+ t.Fatalf("Failed to connect to server: %v\nstdout: %s\nstderr: %s", err, stdoutBuf.String(), stderrBuf.String())
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Errorf("Unexpected status code %d, body: %s", resp.StatusCode, body)
+ }
+}
@@ -0,0 +1,77 @@
+package main
+
+import (
+ _ "embed"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "text/template"
+)
+
+//go:embed prompt.txt
+var promptTemplate string
+
+// SystemPromptData contains all the data needed to render the system prompt template
+type SystemPromptData struct {
+ WorkingDirectory string
+ GitInfo *GitInfo
+}
+
+type GitInfo struct {
+ Root string
+}
+
+// GenerateSystemPrompt generates the system prompt using the embedded template
+func GenerateSystemPrompt() (string, error) {
+ data, err := collectSystemData()
+ if err != nil {
+ return "", fmt.Errorf("failed to collect system data: %w", err)
+ }
+
+ tmpl, err := template.New("system_prompt").Parse(promptTemplate)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ var buf strings.Builder
+ err = tmpl.Execute(&buf, data)
+ if err != nil {
+ return "", fmt.Errorf("failed to execute template: %w", err)
+ }
+
+ return buf.String(), nil
+}
+
+func collectSystemData() (*SystemPromptData, error) {
+ wd, err := os.Getwd()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get working directory: %w", err)
+ }
+
+ data := &SystemPromptData{
+ WorkingDirectory: wd,
+ }
+
+ // Try to collect git info
+ gitInfo, err := collectGitInfo()
+ if err == nil {
+ data.GitInfo = gitInfo
+ }
+
+ return data, nil
+}
+
+func collectGitInfo() (*GitInfo, error) {
+ // Find git root
+ rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
+ rootOutput, err := rootCmd.Output()
+ if err != nil {
+ return nil, err
+ }
+ root := strings.TrimSpace(string(rootOutput))
+
+ return &GitInfo{
+ Root: root,
+ }, nil
+}
@@ -0,0 +1,12 @@
+You are Shelley, a coding agent and assistant. You are an experienced software engineer and architect. You communicate with brevity.
+
+You have access to a variety of tools to get your job done. Be persistent and creative.
+
+Working directory: {{.WorkingDirectory}}
+
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+
+If you are making code changes, make commits with good commit messages before returning to the user.
+{{else}}Not in a git repository.
+{{end}}
@@ -0,0 +1,108 @@
+//go:build linux
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "testing"
+
+ "shelley.exe.dev/seccomp"
+)
+
+// TestSeccompIntegration tests that the seccomp filter is installed
+// automatically and prevents child processes from killing the parent.
+func TestSeccompIntegration(t *testing.T) {
+ if os.Getenv("TEST_SECCOMP_HELPER") == "1" {
+ runSeccompHelper(t)
+ return
+ }
+
+ // Re-exec this test in a subprocess
+ cmd := exec.Command(os.Args[0], "-test.run=TestSeccompIntegration$", "-test.v")
+ cmd.Env = append(os.Environ(), "TEST_SECCOMP_HELPER=1")
+ output, err := cmd.CombinedOutput()
+ t.Logf("Helper output:\n%s", output)
+ if err != nil {
+ t.Fatalf("Helper failed: %v", err)
+ }
+}
+
+func runSeccompHelper(t *testing.T) {
+ pid := os.Getpid()
+ t.Logf("Helper PID: %d", pid)
+
+ // Install seccomp filter (same as -seccomp flag does in main)
+ if err := seccomp.BlockKillSelf(); err != nil {
+ t.Fatalf("BlockKillSelf failed: %v", err)
+ }
+ t.Log("Seccomp filter installed")
+
+ // Spawn a child that tries to kill us
+ script := fmt.Sprintf("kill -TERM %d 2>&1; echo exit=$?", pid)
+ cmd := exec.Command("sh", "-c", script)
+ output, _ := cmd.CombinedOutput()
+ t.Logf("Kill attempt output: %s", output)
+
+ // Verify the kill was blocked (output should contain "Operation not permitted" or exit=1)
+ outStr := string(output)
+ if !strings.Contains(outStr, "Operation not permitted") && !strings.Contains(outStr, "exit=1") {
+ t.Fatalf("Expected kill to fail with Operation not permitted, got: %s", outStr)
+ }
+
+ t.Log("SUCCESS: Child's kill attempt was blocked")
+}
+
+// TestSeccompPreservesKillOthers verifies that with seccomp enabled,
+// we can still kill other processes (not ourselves).
+func TestSeccompPreservesKillOthers(t *testing.T) {
+ if os.Getenv("TEST_SECCOMP_KILL_OTHERS") == "1" {
+ runSeccompKillOthersHelper(t)
+ return
+ }
+
+ // Re-exec this test in a subprocess
+ cmd := exec.Command(os.Args[0], "-test.run=TestSeccompPreservesKillOthers$", "-test.v")
+ cmd.Env = append(os.Environ(), "TEST_SECCOMP_KILL_OTHERS=1")
+ output, err := cmd.CombinedOutput()
+ t.Logf("Helper output:\n%s", output)
+ if err != nil {
+ t.Fatalf("Helper failed: %v", err)
+ }
+}
+
+func runSeccompKillOthersHelper(t *testing.T) {
+ // Install seccomp filter
+ if err := seccomp.BlockKillSelf(); err != nil {
+ t.Fatalf("BlockKillSelf failed: %v", err)
+ }
+ t.Log("Seccomp filter installed")
+
+ // Start a sleep process
+ sleepCmd := exec.Command("sleep", "60")
+ if err := sleepCmd.Start(); err != nil {
+ t.Fatalf("Failed to start sleep: %v", err)
+ }
+ sleepPid := sleepCmd.Process.Pid
+ t.Logf("Started sleep process with PID %d", sleepPid)
+
+ // Kill the sleep process via a child shell - this should work
+ script := fmt.Sprintf("kill -TERM %d 2>&1; echo exit=$?", sleepPid)
+ cmd := exec.Command("sh", "-c", script)
+ output, _ := cmd.CombinedOutput()
+ t.Logf("Kill output: %s", output)
+
+ // Verify the sleep process was killed (exit=0)
+ if !strings.Contains(string(output), "exit=0") {
+ t.Fatalf("Expected kill to succeed, got: %s", output)
+ }
+
+ sleepCmd.Wait()
+ t.Log("SUCCESS: Killing other processes still works")
+}
+
+// Silence unused import warning
+var _ = strconv.Itoa
@@ -0,0 +1,36 @@
+# Database Package
+
+This package provides database operations for the Shelley AI coding agent using SQLite and sqlc.
+
+## Architecture
+
+The database contains two main entities:
+
+- **Conversations**: Represent individual chat sessions with the AI agent
+- **Messages**: Individual messages within conversations (user, agent, or tool messages)
+
+## Testing
+
+Run tests with:
+
+```bash
+go test -v ./db/...
+```
+
+The tests use in-memory SQLite databases and cover all major operations including:
+
+- CRUD operations for conversations and messages
+- Pagination and search functionality
+- JSON data marshalling/unmarshalling
+- Foreign key constraints
+- Transaction handling
+
+## Code Generation
+
+This package uses [sqlc](https://sqlc.dev/) to generate type-safe Go code from SQL queries.
+
+To regenerate code after modifying SQL:
+
+```bash
+go run github.com/sqlc-dev/sqlc/cmd/sqlc generate
+```
@@ -0,0 +1,409 @@
+package db
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/db/generated"
+)
+
+func TestConversationService_Create(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ tests := []struct {
+ name string
+ slug *string
+ }{
+ {
+ name: "with slug",
+ slug: stringPtr("test-conversation"),
+ },
+ {
+ name: "without slug",
+ slug: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conv, err := db.CreateConversation(ctx, tt.slug, true, nil)
+ if err != nil {
+ t.Errorf("Create() error = %v", err)
+ return
+ }
+
+ if conv.ConversationID == "" {
+ t.Error("Expected non-empty conversation ID")
+ }
+
+ if tt.slug != nil {
+ if conv.Slug == nil || *conv.Slug != *tt.slug {
+ t.Errorf("Expected slug %v, got %v", tt.slug, conv.Slug)
+ }
+ } else {
+ if conv.Slug != nil {
+ t.Errorf("Expected nil slug, got %v", conv.Slug)
+ }
+ }
+
+ if conv.CreatedAt.IsZero() {
+ t.Error("Expected non-zero created_at time")
+ }
+
+ if conv.UpdatedAt.IsZero() {
+ t.Error("Expected non-zero updated_at time")
+ }
+ })
+ }
+}
+
+func TestConversationService_GetByID(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Test getting existing conversation
+ conv, err := db.GetConversationByID(ctx, created.ConversationID)
+ if err != nil {
+ t.Errorf("GetByID() error = %v", err)
+ return
+ }
+
+ if conv.ConversationID != created.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", created.ConversationID, conv.ConversationID)
+ }
+
+ // Test getting non-existent conversation
+ _, err = db.GetConversationByID(ctx, "non-existent")
+ if err == nil {
+ t.Error("Expected error for non-existent conversation")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Errorf("Expected 'not found' in error message, got: %v", err)
+ }
+}
+
+func TestConversationService_GetBySlug(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation with slug
+ created, err := db.CreateConversation(ctx, stringPtr("test-slug"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Test getting by existing slug
+ conv, err := db.GetConversationBySlug(ctx, "test-slug")
+ if err != nil {
+ t.Errorf("GetBySlug() error = %v", err)
+ return
+ }
+
+ if conv.ConversationID != created.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", created.ConversationID, conv.ConversationID)
+ }
+
+ // Test getting by non-existent slug
+ _, err = db.GetConversationBySlug(ctx, "non-existent-slug")
+ if err == nil {
+ t.Error("Expected error for non-existent slug")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Errorf("Expected 'not found' in error message, got: %v", err)
+ }
+}
+
+func TestConversationService_UpdateSlug(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ created, err := db.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Update the slug
+ newSlug := "updated-slug"
+ updated, err := db.UpdateConversationSlug(ctx, created.ConversationID, newSlug)
+ if err != nil {
+ t.Errorf("UpdateSlug() error = %v", err)
+ return
+ }
+
+ if updated.Slug == nil || *updated.Slug != newSlug {
+ t.Errorf("Expected slug %s, got %v", newSlug, updated.Slug)
+ }
+
+ // Note: SQLite CURRENT_TIMESTAMP has second precision, so we check >= instead of >
+ if updated.UpdatedAt.Before(created.UpdatedAt) {
+ t.Errorf("Expected updated_at %v to be >= created updated_at %v", updated.UpdatedAt, created.UpdatedAt)
+ }
+}
+
+func TestConversationService_List(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create multiple test conversations
+ for i := 0; i < 5; i++ {
+ slug := stringPtr("conversation-" + string(rune('a'+i)))
+ _, err := db.CreateConversation(ctx, slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation %d: %v", i, err)
+ }
+ }
+
+ // Test listing with pagination
+ conversations, err := db.ListConversations(ctx, 3, 0)
+ if err != nil {
+ t.Errorf("List() error = %v", err)
+ return
+ }
+
+ if len(conversations) != 3 {
+ t.Errorf("Expected 3 conversations, got %d", len(conversations))
+ }
+
+ // The query orders by updated_at DESC, but without sleeps all timestamps
+ // may be identical, so we just verify we got the expected count
+}
+
+func TestConversationService_Search(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create test conversations with different slugs
+ testCases := []string{"project-alpha", "project-beta", "work-task", "personal-note"}
+ for _, slug := range testCases {
+ _, err := db.CreateConversation(ctx, stringPtr(slug), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation with slug %s: %v", slug, err)
+ }
+ }
+
+ // Search for "project" should return 2 conversations
+ results, err := db.SearchConversations(ctx, "project", 10, 0)
+ if err != nil {
+ t.Errorf("Search() error = %v", err)
+ return
+ }
+
+ if len(results) != 2 {
+ t.Errorf("Expected 2 search results, got %d", len(results))
+ }
+
+ // Verify the results contain "project"
+ for _, conv := range results {
+ if conv.Slug == nil || !strings.Contains(*conv.Slug, "project") {
+ t.Errorf("Expected conversation slug to contain 'project', got %v", conv.Slug)
+ }
+ }
+}
+
+func TestConversationService_Touch(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Touch the conversation
+ err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+ return q.UpdateConversationTimestamp(ctx, created.ConversationID)
+ })
+ if err != nil {
+ t.Errorf("Touch() error = %v", err)
+ return
+ }
+
+ // Verify updated_at was changed
+ updated, err := db.GetConversationByID(ctx, created.ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to get conversation after touch: %v", err)
+ }
+
+ // Note: SQLite CURRENT_TIMESTAMP has second precision, so we check >= instead of >
+ if updated.UpdatedAt.Before(created.UpdatedAt) {
+ t.Errorf("Expected updated_at %v to be >= created updated_at %v", updated.UpdatedAt, created.UpdatedAt)
+ }
+}
+
+func TestConversationService_Delete(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Delete the conversation
+ err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+ return q.DeleteConversation(ctx, created.ConversationID)
+ })
+ if err != nil {
+ t.Errorf("Delete() error = %v", err)
+ return
+ }
+
+ // Verify it's gone
+ _, err = db.GetConversationByID(ctx, created.ConversationID)
+ if err == nil {
+ t.Error("Expected error when getting deleted conversation")
+ }
+}
+
+func TestConversationService_Count(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Initial count should be 0
+ var count int64
+ err := db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountConversations(ctx)
+ return err
+ })
+ if err != nil {
+ t.Errorf("Count() error = %v", err)
+ return
+ }
+ if count != 0 {
+ t.Errorf("Expected initial count 0, got %d", count)
+ }
+
+ // Create test conversations
+ for i := 0; i < 3; i++ {
+ _, err := db.CreateConversation(ctx, stringPtr("conversation-"+string(rune('a'+i))), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation %d: %v", i, err)
+ }
+ }
+
+ // Count should now be 3
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountConversations(ctx)
+ return err
+ })
+ if err != nil {
+ t.Errorf("Count() error = %v", err)
+ return
+ }
+ if count != 3 {
+ t.Errorf("Expected count 3, got %d", count)
+ }
+}
+
+func TestConversationService_MultipleNullSlugs(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create multiple conversations with null slugs - this should not fail
+ conv1, err := db.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Errorf("Create() first conversation error = %v", err)
+ return
+ }
+
+ conv2, err := db.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Errorf("Create() second conversation error = %v", err)
+ return
+ }
+
+ // Both should have null slugs
+ if conv1.Slug != nil {
+ t.Errorf("Expected first conversation slug to be nil, got %v", conv1.Slug)
+ }
+ if conv2.Slug != nil {
+ t.Errorf("Expected second conversation slug to be nil, got %v", conv2.Slug)
+ }
+
+ // They should have different IDs
+ if conv1.ConversationID == conv2.ConversationID {
+ t.Error("Expected different conversation IDs")
+ }
+}
+
+func TestConversationService_SlugUniquenessWhenNotNull(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create first conversation with a slug
+ _, err := db.CreateConversation(ctx, stringPtr("unique-slug"), true, nil)
+ if err != nil {
+ t.Errorf("Create() first conversation error = %v", err)
+ return
+ }
+
+ // Try to create second conversation with the same slug - this should fail
+ _, err = db.CreateConversation(ctx, stringPtr("unique-slug"), true, nil)
+ if err == nil {
+ t.Error("Expected error when creating conversation with duplicate slug")
+ return
+ }
+
+ // Verify the error is related to uniqueness constraint
+ if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+ t.Errorf("Expected UNIQUE constraint error, got: %v", err)
+ }
+}
@@ -0,0 +1,579 @@
+// Package db provides database operations for the Shelley AI coding agent.
+package db
+
+//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate -f ../sqlc.yaml
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/google/uuid"
+ "shelley.exe.dev/db/generated"
+
+ _ "modernc.org/sqlite"
+)
+
+//go:embed schema/*.sql
+var schemaFS embed.FS
+
+// generateConversationID generates a conversation ID in the format "cXXXXXX"
+// where X are random alphanumeric characters
+func generateConversationID() (string, error) {
+ text := rand.Text()
+ if len(text) < 6 {
+ return "", fmt.Errorf("rand.Text() returned insufficient characters: %d", len(text))
+ }
+ return "c" + text[:6], nil
+}
+
+// DB wraps the database connection pool and provides high-level operations
+type DB struct {
+ pool *Pool
+}
+
+// Config holds database configuration
+type Config struct {
+ DSN string // Data Source Name for SQLite database
+}
+
+// New creates a new database connection with the given configuration
+func New(cfg Config) (*DB, error) {
+ if cfg.DSN == "" {
+ return nil, fmt.Errorf("database DSN cannot be empty")
+ }
+
+ if cfg.DSN == ":memory:" {
+ return nil, fmt.Errorf(":memory: database not supported (requires multiple connections); use a temp file")
+ }
+
+ // Ensure directory exists for file-based SQLite databases
+ if cfg.DSN != ":memory:" {
+ dir := filepath.Dir(cfg.DSN)
+ if dir != "." && dir != "" {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create database directory: %w", err)
+ }
+ }
+ }
+
+ // Create connection pool with 3 readers
+ dsn := cfg.DSN
+ if !strings.Contains(dsn, "?") {
+ dsn += "?_foreign_keys=on"
+ } else if !strings.Contains(dsn, "_foreign_keys") {
+ dsn += "&_foreign_keys=on"
+ }
+
+ pool, err := NewPool(dsn, 3)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create connection pool: %w", err)
+ }
+
+ return &DB{
+ pool: pool,
+ }, nil
+}
+
+// Close closes the database connection pool
+func (db *DB) Close() error {
+ return db.pool.Close()
+}
+
+// Migrate runs the database migrations
+func (db *DB) Migrate(ctx context.Context) error {
+ // Read all migration files
+ entries, err := schemaFS.ReadDir("schema")
+ if err != nil {
+ return fmt.Errorf("failed to read schema directory: %w", err)
+ }
+
+ // Filter and validate migration files
+ var migrations []string
+ migrationPattern := regexp.MustCompile(`^(\d{3})-.*\.sql$`)
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if !migrationPattern.MatchString(entry.Name()) {
+ continue
+ }
+ migrations = append(migrations, entry.Name())
+ }
+
+ // Sort migrations by number
+ sort.Strings(migrations)
+
+ // Get executed migrations
+ executedMigrations := make(map[int]bool)
+ var tableName string
+ err = db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ row := rx.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'")
+ return row.Scan(&tableName)
+ })
+
+ if err == nil {
+ // Migrations table exists, load executed migrations
+ err = db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ rows, err := rx.Query("SELECT migration_number FROM migrations")
+ if err != nil {
+ return fmt.Errorf("failed to query executed migrations: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var migrationNumber int
+ if err := rows.Scan(&migrationNumber); err != nil {
+ return fmt.Errorf("failed to scan migration number: %w", err)
+ }
+ executedMigrations[migrationNumber] = true
+ }
+ return rows.Err()
+ })
+ if err != nil {
+ return fmt.Errorf("failed to load executed migrations: %w", err)
+ }
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ // Migrations table doesn't exist - executedMigrations remains empty
+ slog.Info("migrations table not found, running all migrations")
+ }
+
+ // Run any migrations that haven't been executed
+ for _, migration := range migrations {
+ // Extract migration number from filename (e.g., "001-base.sql" -> 001)
+ matches := migrationPattern.FindStringSubmatch(migration)
+ if len(matches) != 2 {
+ return fmt.Errorf("invalid migration filename format: %s", migration)
+ }
+
+ migrationNumber, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return fmt.Errorf("failed to parse migration number from %s: %w", migration, err)
+ }
+
+ if !executedMigrations[migrationNumber] {
+ slog.Info("running migration", "file", migration, "number", migrationNumber)
+ if err := db.executeMigration(ctx, migration); err != nil {
+ return fmt.Errorf("failed to execute migration %s: %w", migration, err)
+ }
+
+ err = db.pool.Exec(ctx, "INSERT INTO migrations (migration_number, migration_name) VALUES (?, ?)", migrationNumber, migration)
+ if err != nil {
+ return fmt.Errorf("failed to record migration %s in migrations table: %w", migration, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// executeMigration executes a single migration file
+func (db *DB) executeMigration(ctx context.Context, filename string) error {
+ content, err := schemaFS.ReadFile("schema/" + filename)
+ if err != nil {
+ return fmt.Errorf("failed to read migration file %s: %w", filename, err)
+ }
+
+ if err := db.pool.Exec(ctx, string(content)); err != nil {
+ return fmt.Errorf("failed to execute migration %s: %w", filename, err)
+ }
+
+ return nil
+}
+
+// Pool returns the underlying connection pool for advanced operations
+func (db *DB) Pool() *Pool {
+ return db.pool
+}
+
+// WithTx runs a function within a database transaction
+func (db *DB) WithTx(ctx context.Context, fn func(*generated.Queries) error) error {
+ return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ queries := generated.New(tx.Conn())
+ return fn(queries)
+ })
+}
+
+// WithTxRes runs a function within a database transaction and returns a value
+func WithTxRes[T any](db *DB, ctx context.Context, fn func(*generated.Queries) (T, error)) (T, error) {
+ var result T
+ err := db.WithTx(ctx, func(queries *generated.Queries) error {
+ var err error
+ result, err = fn(queries)
+ return err
+ })
+ return result, err
+}
+
+// Conversation methods (moved from ConversationService)
+
+// CreateConversation creates a new conversation with an optional slug
+func (db *DB) CreateConversation(ctx context.Context, slug *string, userInitiated bool, cwd *string) (*generated.Conversation, error) {
+ conversationID, err := generateConversationID()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate conversation ID: %w", err)
+ }
+ var conversation generated.Conversation
+ err = db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ conversation, err = q.CreateConversation(ctx, generated.CreateConversationParams{
+ ConversationID: conversationID,
+ Slug: slug,
+ UserInitiated: userInitiated,
+ Cwd: cwd,
+ })
+ return err
+ })
+ return &conversation, err
+}
+
+// GetConversationByID retrieves a conversation by its ID
+func (db *DB) GetConversationByID(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversation, err = q.GetConversation(ctx, conversationID)
+ return err
+ })
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("conversation not found: %s", conversationID)
+ }
+ return &conversation, err
+}
+
+// GetConversationBySlug retrieves a conversation by its slug
+func (db *DB) GetConversationBySlug(ctx context.Context, slug string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversation, err = q.GetConversationBySlug(ctx, &slug)
+ return err
+ })
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("conversation not found with slug: %s", slug)
+ }
+ return &conversation, err
+}
+
+// ListConversations retrieves conversations with pagination
+func (db *DB) ListConversations(ctx context.Context, limit, offset int64) ([]generated.Conversation, error) {
+ var conversations []generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversations, err = q.ListConversations(ctx, generated.ListConversationsParams{
+ Limit: limit,
+ Offset: offset,
+ })
+ return err
+ })
+ return conversations, err
+}
+
+// SearchConversations searches for conversations containing the given query in their slug
+func (db *DB) SearchConversations(ctx context.Context, query string, limit, offset int64) ([]generated.Conversation, error) {
+ queryPtr := &query
+ var conversations []generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversations, err = q.SearchConversations(ctx, generated.SearchConversationsParams{
+ Column1: queryPtr,
+ Limit: limit,
+ Offset: offset,
+ })
+ return err
+ })
+ return conversations, err
+}
+
+// UpdateConversationSlug updates the slug of a conversation
+func (db *DB) UpdateConversationSlug(ctx context.Context, conversationID, slug string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ var err error
+ conversation, err = q.UpdateConversationSlug(ctx, generated.UpdateConversationSlugParams{
+ Slug: &slug,
+ ConversationID: conversationID,
+ })
+ return err
+ })
+ return &conversation, err
+}
+
+// UpdateConversationCwd updates the working directory for a conversation
+func (db *DB) UpdateConversationCwd(ctx context.Context, conversationID, cwd string) error {
+ return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ _, err := q.UpdateConversationCwd(ctx, generated.UpdateConversationCwdParams{
+ Cwd: &cwd,
+ ConversationID: conversationID,
+ })
+ return err
+ })
+}
+
+// Message methods (moved from MessageService)
+
+// MessageType represents the type of message
+type MessageType string
+
+const (
+ MessageTypeUser MessageType = "user"
+ MessageTypeAgent MessageType = "agent"
+ MessageTypeTool MessageType = "tool"
+ MessageTypeSystem MessageType = "system"
+ MessageTypeError MessageType = "error"
+)
+
+// CreateMessageParams contains parameters for creating a message
+type CreateMessageParams struct {
+ ConversationID string
+ Type MessageType
+ LLMData interface{} // Will be JSON marshalled
+ UserData interface{} // Will be JSON marshalled
+ UsageData interface{} // Will be JSON marshalled
+ DisplayData interface{} // Will be JSON marshalled, tool-specific display content
+}
+
+// CreateMessage creates a new message
+func (db *DB) CreateMessage(ctx context.Context, params CreateMessageParams) (*generated.Message, error) {
+ messageID := uuid.New().String()
+
+ // Marshal JSON fields
+ var llmDataJSON, userDataJSON, usageDataJSON, displayDataJSON *string
+
+ if params.LLMData != nil {
+ data, err := json.Marshal(params.LLMData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal LLM data: %w", err)
+ }
+ str := string(data)
+ llmDataJSON = &str
+ }
+
+ if params.UserData != nil {
+ data, err := json.Marshal(params.UserData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal user data: %w", err)
+ }
+ str := string(data)
+ userDataJSON = &str
+ }
+
+ if params.UsageData != nil {
+ data, err := json.Marshal(params.UsageData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal usage data: %w", err)
+ }
+ str := string(data)
+ usageDataJSON = &str
+ }
+
+ if params.DisplayData != nil {
+ data, err := json.Marshal(params.DisplayData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal display data: %w", err)
+ }
+ str := string(data)
+ displayDataJSON = &str
+ }
+
+ var message generated.Message
+ err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+
+ // Get next sequence_id for this conversation
+ sequenceID, err := q.GetNextSequenceID(ctx, params.ConversationID)
+ if err != nil {
+ return fmt.Errorf("failed to get next sequence ID: %w", err)
+ }
+
+ message, err = q.CreateMessage(ctx, generated.CreateMessageParams{
+ MessageID: messageID,
+ ConversationID: params.ConversationID,
+ SequenceID: sequenceID,
+ Type: string(params.Type),
+ LlmData: llmDataJSON,
+ UserData: userDataJSON,
+ UsageData: usageDataJSON,
+ DisplayData: displayDataJSON,
+ })
+ return err
+ })
+ return &message, err
+}
+
+// GetMessageByID retrieves a message by its ID
+func (db *DB) GetMessageByID(ctx context.Context, messageID string) (*generated.Message, error) {
+ var message generated.Message
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ message, err = q.GetMessage(ctx, messageID)
+ return err
+ })
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("message not found: %s", messageID)
+ }
+ return &message, err
+}
+
+// ListMessagesByConversationPaginated retrieves messages in a conversation with pagination
+func (db *DB) ListMessagesByConversationPaginated(ctx context.Context, conversationID string, limit, offset int64) ([]generated.Message, error) {
+ var messages []generated.Message
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ messages, err = q.ListMessagesPaginated(ctx, generated.ListMessagesPaginatedParams{
+ ConversationID: conversationID,
+ Limit: limit,
+ Offset: offset,
+ })
+ return err
+ })
+ return messages, err
+}
+
+// ListMessagesByType retrieves messages of a specific type in a conversation
+func (db *DB) ListMessagesByType(ctx context.Context, conversationID string, messageType MessageType) ([]generated.Message, error) {
+ var messages []generated.Message
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ messages, err = q.ListMessagesByType(ctx, generated.ListMessagesByTypeParams{
+ ConversationID: conversationID,
+ Type: string(messageType),
+ })
+ return err
+ })
+ return messages, err
+}
+
+// GetLatestMessage retrieves the latest message in a conversation
+func (db *DB) GetLatestMessage(ctx context.Context, conversationID string) (*generated.Message, error) {
+ var message generated.Message
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ message, err = q.GetLatestMessage(ctx, conversationID)
+ return err
+ })
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("no messages found in conversation: %s", conversationID)
+ }
+ return &message, err
+}
+
+// CountMessagesByType returns the number of messages of a specific type in a conversation
+func (db *DB) CountMessagesByType(ctx context.Context, conversationID string, messageType MessageType) (int64, error) {
+ var count int64
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ count, err = q.CountMessagesByType(ctx, generated.CountMessagesByTypeParams{
+ ConversationID: conversationID,
+ Type: string(messageType),
+ })
+ return err
+ })
+ return count, err
+}
+
+// Queries provides read-only access to generated queries within a read transaction
+func (db *DB) Queries(ctx context.Context, fn func(*generated.Queries) error) error {
+ return db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ return fn(q)
+ })
+}
+
+// QueriesTx provides read-write access to generated queries within a write transaction
+func (db *DB) QueriesTx(ctx context.Context, fn func(*generated.Queries) error) error {
+ return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ return fn(q)
+ })
+}
+
+// ListArchivedConversations retrieves archived conversations with pagination
+func (db *DB) ListArchivedConversations(ctx context.Context, limit, offset int64) ([]generated.Conversation, error) {
+ var conversations []generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversations, err = q.ListArchivedConversations(ctx, generated.ListArchivedConversationsParams{
+ Limit: limit,
+ Offset: offset,
+ })
+ return err
+ })
+ return conversations, err
+}
+
+// SearchArchivedConversations searches for archived conversations containing the given query in their slug
+func (db *DB) SearchArchivedConversations(ctx context.Context, query string, limit, offset int64) ([]generated.Conversation, error) {
+ queryPtr := &query
+ var conversations []generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversations, err = q.SearchArchivedConversations(ctx, generated.SearchArchivedConversationsParams{
+ Column1: queryPtr,
+ Limit: limit,
+ Offset: offset,
+ })
+ return err
+ })
+ return conversations, err
+}
+
+// ArchiveConversation archives a conversation
+func (db *DB) ArchiveConversation(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ var err error
+ conversation, err = q.ArchiveConversation(ctx, conversationID)
+ return err
+ })
+ return &conversation, err
+}
+
+// UnarchiveConversation unarchives a conversation
+func (db *DB) UnarchiveConversation(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ var err error
+ conversation, err = q.UnarchiveConversation(ctx, conversationID)
+ return err
+ })
+ return &conversation, err
+}
+
+// DeleteConversation deletes a conversation and all its messages
+func (db *DB) DeleteConversation(ctx context.Context, conversationID string) error {
+ return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ // Delete messages first (foreign key constraint)
+ if err := q.DeleteConversationMessages(ctx, conversationID); err != nil {
+ return fmt.Errorf("failed to delete messages: %w", err)
+ }
+ return q.DeleteConversation(ctx, conversationID)
+ })
+}
@@ -0,0 +1,178 @@
+package db
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/db/generated"
+)
+
+// setupTestDB creates a test database with schema migrated
+func setupTestDB(t *testing.T) *DB {
+ t.Helper()
+
+ // Use a temporary file instead of :memory: because the pool requires multiple connections
+ tmpDir := t.TempDir()
+ db, err := New(Config{DSN: tmpDir + "/test.db"})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := db.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate test database: %v", err)
+ }
+
+ return db
+}
+
+func TestNew(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg Config
+ wantErr bool
+ }{
+ {
+ name: "memory database not supported",
+ cfg: Config{DSN: ":memory:"},
+ wantErr: true,
+ },
+ {
+ name: "empty DSN",
+ cfg: Config{DSN: ""},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db, err := New(tt.cfg)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if db != nil {
+ defer db.Close()
+ }
+ })
+ }
+}
+
+func TestDB_Migrate(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := New(Config{DSN: tmpDir + "/test.db"})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer db.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Run migrations first time
+ if err := db.Migrate(ctx); err != nil {
+ t.Errorf("Migrate() error = %v", err)
+ }
+
+ // Verify tables were created by trying to count conversations
+ var count int64
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountConversations(ctx)
+ return err
+ })
+ if err != nil {
+ t.Errorf("Failed to query conversations after migration: %v", err)
+ }
+ if count != 0 {
+ t.Errorf("Expected 0 conversations, got %d", count)
+ }
+
+ // Run migrations a second time to verify idempotency
+ if err := db.Migrate(ctx); err != nil {
+ t.Errorf("Second Migrate() error = %v", err)
+ }
+
+ // Verify we can still query after running migrations twice
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountConversations(ctx)
+ return err
+ })
+ if err != nil {
+ t.Errorf("Failed to query conversations after second migration: %v", err)
+ }
+}
+
+func TestDB_WithTx(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Test successful transaction
+ err := db.WithTx(ctx, func(q *generated.Queries) error {
+ _, err := q.CreateConversation(ctx, generated.CreateConversationParams{
+ ConversationID: "test-conv-1",
+ Slug: stringPtr("test-slug"),
+ UserInitiated: true,
+ })
+ return err
+ })
+ if err != nil {
+ t.Errorf("WithTx() error = %v", err)
+ }
+
+ // Verify the conversation was created
+ var conv generated.Conversation
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ conv, err = q.GetConversation(ctx, "test-conv-1")
+ return err
+ })
+ if err != nil {
+ t.Errorf("Failed to get conversation after transaction: %v", err)
+ }
+ if conv.ConversationID != "test-conv-1" {
+ t.Errorf("Expected conversation ID 'test-conv-1', got %s", conv.ConversationID)
+ }
+}
+
+// stringPtr returns a pointer to the given string
+func stringPtr(s string) *string {
+ return &s
+}
+
+func TestDB_ForeignKeyConstraints(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Try to create a message with a non-existent conversation_id
+ // This should fail due to foreign key constraint
+ err := db.QueriesTx(ctx, func(q *generated.Queries) error {
+ _, err := q.CreateMessage(ctx, generated.CreateMessageParams{
+ MessageID: "test-msg-1",
+ ConversationID: "non-existent-conversation",
+ Type: "user",
+ })
+ return err
+ })
+
+ if err == nil {
+ t.Error("Expected error when creating message with non-existent conversation_id")
+ return
+ }
+
+ // Verify the error is related to foreign key constraint
+ if !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
+ t.Errorf("Expected foreign key constraint error, got: %v", err)
+ }
+}
@@ -0,0 +1,398 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: conversations.sql
+
+package generated
+
+import (
+ "context"
+)
+
+const archiveConversation = `-- name: ArchiveConversation :one
+UPDATE conversations
+SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, archiveConversation, conversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const countArchivedConversations = `-- name: CountArchivedConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = TRUE
+`
+
+func (q *Queries) CountArchivedConversations(ctx context.Context) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countArchivedConversations)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const countConversations = `-- name: CountConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE
+`
+
+func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countConversations)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const createConversation = `-- name: CreateConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
+VALUES (?, ?, ?, ?)
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type CreateConversationParams struct {
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ UserInitiated bool `json:"user_initiated"`
+ Cwd *string `json:"cwd"`
+}
+
+func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, createConversation,
+ arg.ConversationID,
+ arg.Slug,
+ arg.UserInitiated,
+ arg.Cwd,
+ )
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const deleteConversation = `-- name: DeleteConversation :exec
+DELETE FROM conversations
+WHERE conversation_id = ?
+`
+
+func (q *Queries) DeleteConversation(ctx context.Context, conversationID string) error {
+ _, err := q.db.ExecContext(ctx, deleteConversation, conversationID)
+ return err
+}
+
+const getConversation = `-- name: GetConversation :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE conversation_id = ?
+`
+
+func (q *Queries) GetConversation(ctx context.Context, conversationID string) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, getConversation, conversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const getConversationBySlug = `-- name: GetConversationBySlug :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug = ?
+`
+
+func (q *Queries) GetConversationBySlug(ctx context.Context, slug *string) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, getConversationBySlug, slug)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const listArchivedConversations = `-- name: ListArchivedConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type ListArchivedConversationsParams struct {
+ Limit int64 `json:"limit"`
+ Offset int64 `json:"offset"`
+}
+
+func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchivedConversationsParams) ([]Conversation, error) {
+ rows, err := q.db.QueryContext(ctx, listArchivedConversations, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Conversation{}
+ for rows.Next() {
+ var i Conversation
+ if err := rows.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listConversations = `-- name: ListConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type ListConversationsParams struct {
+ Limit int64 `json:"limit"`
+ Offset int64 `json:"offset"`
+}
+
+func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsParams) ([]Conversation, error) {
+ rows, err := q.db.QueryContext(ctx, listConversations, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Conversation{}
+ for rows.Next() {
+ var i Conversation
+ if err := rows.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const searchArchivedConversations = `-- name: SearchArchivedConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type SearchArchivedConversationsParams struct {
+ Column1 *string `json:"column_1"`
+ Limit int64 `json:"limit"`
+ Offset int64 `json:"offset"`
+}
+
+func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArchivedConversationsParams) ([]Conversation, error) {
+ rows, err := q.db.QueryContext(ctx, searchArchivedConversations, arg.Column1, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Conversation{}
+ for rows.Next() {
+ var i Conversation
+ if err := rows.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const searchConversations = `-- name: SearchConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type SearchConversationsParams struct {
+ Column1 *string `json:"column_1"`
+ Limit int64 `json:"limit"`
+ Offset int64 `json:"offset"`
+}
+
+func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversationsParams) ([]Conversation, error) {
+ rows, err := q.db.QueryContext(ctx, searchConversations, arg.Column1, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Conversation{}
+ for rows.Next() {
+ var i Conversation
+ if err := rows.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const unarchiveConversation = `-- name: UnarchiveConversation :one
+UPDATE conversations
+SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, unarchiveConversation, conversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const updateConversationCwd = `-- name: UpdateConversationCwd :one
+UPDATE conversations
+SET cwd = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type UpdateConversationCwdParams struct {
+ Cwd *string `json:"cwd"`
+ ConversationID string `json:"conversation_id"`
+}
+
+func (q *Queries) UpdateConversationCwd(ctx context.Context, arg UpdateConversationCwdParams) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, updateConversationCwd, arg.Cwd, arg.ConversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const updateConversationSlug = `-- name: UpdateConversationSlug :one
+UPDATE conversations
+SET slug = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type UpdateConversationSlugParams struct {
+ Slug *string `json:"slug"`
+ ConversationID string `json:"conversation_id"`
+}
+
+func (q *Queries) UpdateConversationSlug(ctx context.Context, arg UpdateConversationSlugParams) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, updateConversationSlug, arg.Slug, arg.ConversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const updateConversationTimestamp = `-- name: UpdateConversationTimestamp :exec
+UPDATE conversations
+SET updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+`
+
+func (q *Queries) UpdateConversationTimestamp(ctx context.Context, conversationID string) error {
+ _, err := q.db.ExecContext(ctx, updateConversationTimestamp, conversationID)
+ return err
+}
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package generated
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
@@ -0,0 +1,334 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: messages.sql
+
+package generated
+
+import (
+ "context"
+)
+
+const countMessagesByType = `-- name: CountMessagesByType :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ? AND type = ?
+`
+
+type CountMessagesByTypeParams struct {
+ ConversationID string `json:"conversation_id"`
+ Type string `json:"type"`
+}
+
+func (q *Queries) CountMessagesByType(ctx context.Context, arg CountMessagesByTypeParams) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countMessagesByType, arg.ConversationID, arg.Type)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const countMessagesInConversation = `-- name: CountMessagesInConversation :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ?
+`
+
+func (q *Queries) CountMessagesInConversation(ctx context.Context, conversationID string) (int64, error) {
+ row := q.db.QueryRowContext(ctx, countMessagesInConversation, conversationID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const createMessage = `-- name: CreateMessage :one
+INSERT INTO messages (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, display_data)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data
+`
+
+type CreateMessageParams struct {
+ MessageID string `json:"message_id"`
+ ConversationID string `json:"conversation_id"`
+ SequenceID int64 `json:"sequence_id"`
+ Type string `json:"type"`
+ LlmData *string `json:"llm_data"`
+ UserData *string `json:"user_data"`
+ UsageData *string `json:"usage_data"`
+ DisplayData *string `json:"display_data"`
+}
+
+func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
+ row := q.db.QueryRowContext(ctx, createMessage,
+ arg.MessageID,
+ arg.ConversationID,
+ arg.SequenceID,
+ arg.Type,
+ arg.LlmData,
+ arg.UserData,
+ arg.UsageData,
+ arg.DisplayData,
+ )
+ var i Message
+ err := row.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ )
+ return i, err
+}
+
+const deleteConversationMessages = `-- name: DeleteConversationMessages :exec
+DELETE FROM messages
+WHERE conversation_id = ?
+`
+
+func (q *Queries) DeleteConversationMessages(ctx context.Context, conversationID string) error {
+ _, err := q.db.ExecContext(ctx, deleteConversationMessages, conversationID)
+ return err
+}
+
+const deleteMessage = `-- name: DeleteMessage :exec
+DELETE FROM messages
+WHERE message_id = ?
+`
+
+func (q *Queries) DeleteMessage(ctx context.Context, messageID string) error {
+ _, err := q.db.ExecContext(ctx, deleteMessage, messageID)
+ return err
+}
+
+const getLatestMessage = `-- name: GetLatestMessage :one
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id DESC
+LIMIT 1
+`
+
+func (q *Queries) GetLatestMessage(ctx context.Context, conversationID string) (Message, error) {
+ row := q.db.QueryRowContext(ctx, getLatestMessage, conversationID)
+ var i Message
+ err := row.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ )
+ return i, err
+}
+
+const getMessage = `-- name: GetMessage :one
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE message_id = ?
+`
+
+func (q *Queries) GetMessage(ctx context.Context, messageID string) (Message, error) {
+ row := q.db.QueryRowContext(ctx, getMessage, messageID)
+ var i Message
+ err := row.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ )
+ return i, err
+}
+
+const getNextSequenceID = `-- name: GetNextSequenceID :one
+SELECT COALESCE(MAX(sequence_id), 0) + 1
+FROM messages
+WHERE conversation_id = ?
+`
+
+func (q *Queries) GetNextSequenceID(ctx context.Context, conversationID string) (int64, error) {
+ row := q.db.QueryRowContext(ctx, getNextSequenceID, conversationID)
+ var column_1 int64
+ err := row.Scan(&column_1)
+ return column_1, err
+}
+
+const listMessages = `-- name: ListMessages :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+`
+
+func (q *Queries) ListMessages(ctx context.Context, conversationID string) ([]Message, error) {
+ rows, err := q.db.QueryContext(ctx, listMessages, conversationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listMessagesByType = `-- name: ListMessagesByType :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ? AND type = ?
+ORDER BY sequence_id ASC
+`
+
+type ListMessagesByTypeParams struct {
+ ConversationID string `json:"conversation_id"`
+ Type string `json:"type"`
+}
+
+func (q *Queries) ListMessagesByType(ctx context.Context, arg ListMessagesByTypeParams) ([]Message, error) {
+ rows, err := q.db.QueryContext(ctx, listMessagesByType, arg.ConversationID, arg.Type)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listMessagesPaginated = `-- name: ListMessagesPaginated :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+LIMIT ? OFFSET ?
+`
+
+type ListMessagesPaginatedParams struct {
+ ConversationID string `json:"conversation_id"`
+ Limit int64 `json:"limit"`
+ Offset int64 `json:"offset"`
+}
+
+func (q *Queries) ListMessagesPaginated(ctx context.Context, arg ListMessagesPaginatedParams) ([]Message, error) {
+ rows, err := q.db.QueryContext(ctx, listMessagesPaginated, arg.ConversationID, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listMessagesSince = `-- name: ListMessagesSince :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ? AND sequence_id > ?
+ORDER BY sequence_id ASC
+`
+
+type ListMessagesSinceParams struct {
+ ConversationID string `json:"conversation_id"`
+ SequenceID int64 `json:"sequence_id"`
+}
+
+func (q *Queries) ListMessagesSince(ctx context.Context, arg ListMessagesSinceParams) ([]Message, error) {
+ rows, err := q.db.QueryContext(ctx, listMessagesSince, arg.ConversationID, arg.SequenceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.MessageID,
+ &i.ConversationID,
+ &i.SequenceID,
+ &i.Type,
+ &i.LlmData,
+ &i.UserData,
+ &i.UsageData,
+ &i.CreatedAt,
+ &i.DisplayData,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
@@ -0,0 +1,37 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package generated
+
+import (
+ "time"
+)
+
+type Conversation struct {
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ UserInitiated bool `json:"user_initiated"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Cwd *string `json:"cwd"`
+ Archived bool `json:"archived"`
+}
+
+type Message struct {
+ MessageID string `json:"message_id"`
+ ConversationID string `json:"conversation_id"`
+ SequenceID int64 `json:"sequence_id"`
+ Type string `json:"type"`
+ LlmData *string `json:"llm_data"`
+ UserData *string `json:"user_data"`
+ UsageData *string `json:"usage_data"`
+ CreatedAt time.Time `json:"created_at"`
+ DisplayData *string `json:"display_data"`
+}
+
+type Migration struct {
+ MigrationNumber int64 `json:"migration_number"`
+ MigrationName string `json:"migration_name"`
+ ExecutedAt *time.Time `json:"executed_at"`
+}
@@ -0,0 +1,457 @@
+package db
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/db/generated"
+)
+
+func TestMessageService_Create(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ msgType MessageType
+ llmData interface{}
+ userData interface{}
+ usageData interface{}
+ }{
+ {
+ name: "user message with data",
+ msgType: MessageTypeUser,
+ llmData: map[string]string{"content": "Hello, AI!"},
+ userData: map[string]string{"display": "Hello, AI!"},
+ usageData: nil,
+ },
+ {
+ name: "agent message with usage",
+ msgType: MessageTypeAgent,
+ llmData: map[string]string{"response": "Hello, human!"},
+ userData: map[string]string{"formatted": "Hello, human!"},
+ usageData: map[string]int{"tokens": 42},
+ },
+ {
+ name: "tool message minimal",
+ msgType: MessageTypeTool,
+ llmData: nil,
+ userData: nil,
+ usageData: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msg, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: tt.msgType,
+ LLMData: tt.llmData,
+ UserData: tt.userData,
+ UsageData: tt.usageData,
+ })
+ if err != nil {
+ t.Errorf("Create() error = %v", err)
+ return
+ }
+
+ if msg.MessageID == "" {
+ t.Error("Expected non-empty message ID")
+ }
+
+ if msg.ConversationID != conv.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, msg.ConversationID)
+ }
+
+ if msg.Type != string(tt.msgType) {
+ t.Errorf("Expected message type %s, got %s", tt.msgType, msg.Type)
+ }
+
+ // Test JSON data marshalling
+ if tt.llmData != nil {
+ if msg.LlmData == nil {
+ t.Error("Expected LLM data to be non-nil")
+ } else {
+ var unmarshalled map[string]interface{}
+ err := json.Unmarshal([]byte(*msg.LlmData), &unmarshalled)
+ if err != nil {
+ t.Errorf("Failed to unmarshal LLM data: %v", err)
+ }
+ }
+ } else {
+ if msg.LlmData != nil {
+ t.Error("Expected LLM data to be nil")
+ }
+ }
+
+ if msg.CreatedAt.IsZero() {
+ t.Error("Expected non-zero created_at time")
+ }
+ })
+ }
+}
+
+func TestMessageService_GetByID(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Create a test message
+ created, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: MessageTypeUser,
+ LLMData: map[string]string{"content": "test message"},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message: %v", err)
+ }
+
+ // Test getting existing message
+ msg, err := db.GetMessageByID(ctx, created.MessageID)
+ if err != nil {
+ t.Errorf("GetByID() error = %v", err)
+ return
+ }
+
+ if msg.MessageID != created.MessageID {
+ t.Errorf("Expected message ID %s, got %s", created.MessageID, msg.MessageID)
+ }
+
+ // Test getting non-existent message
+ _, err = db.GetMessageByID(ctx, "non-existent")
+ if err == nil {
+ t.Error("Expected error for non-existent message")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Errorf("Expected 'not found' in error message, got: %v", err)
+ }
+}
+
+func TestMessageService_ListByConversation(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Create multiple test messages
+ msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeTool}
+ for i, msgType := range msgTypes {
+ _, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: msgType,
+ LLMData: map[string]interface{}{"index": i, "type": string(msgType)},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message %d: %v", i, err)
+ }
+ }
+
+ // List messages
+ var messages []generated.Message
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ messages, err = q.ListMessages(ctx, conv.ConversationID)
+ return err
+ })
+ if err != nil {
+ t.Errorf("ListByConversation() error = %v", err)
+ return
+ }
+
+ if len(messages) != 3 {
+ t.Errorf("Expected 3 messages, got %d", len(messages))
+ }
+
+ // Messages should be ordered by created_at ASC (oldest first) by the query
+ // We verify this by checking the message types are in the order we created them
+ expectedTypes := []string{"user", "agent", "tool"}
+ for i, msg := range messages {
+ if msg.Type != expectedTypes[i] {
+ t.Errorf("Expected message %d to be type %s, got %s", i, expectedTypes[i], msg.Type)
+ }
+ }
+}
+
+func TestMessageService_ListByType(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Create messages of different types
+ msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeUser, MessageTypeTool}
+ for i, msgType := range msgTypes {
+ _, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: msgType,
+ LLMData: map[string]interface{}{"index": i},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message %d: %v", i, err)
+ }
+ }
+
+ // List only user messages
+ userMessages, err := db.ListMessagesByType(ctx, conv.ConversationID, MessageTypeUser)
+ if err != nil {
+ t.Errorf("ListByType() error = %v", err)
+ return
+ }
+
+ if len(userMessages) != 2 {
+ t.Errorf("Expected 2 user messages, got %d", len(userMessages))
+ }
+
+ // Verify all messages are user type
+ for _, msg := range userMessages {
+ if msg.Type != string(MessageTypeUser) {
+ t.Errorf("Expected user message, got %s", msg.Type)
+ }
+ }
+}
+
+func TestMessageService_GetLatest(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Test getting latest from empty conversation
+ _, err = db.GetLatestMessage(ctx, conv.ConversationID)
+ if err == nil {
+ t.Error("Expected error for conversation with no messages")
+ }
+
+ // Create multiple test messages
+ var lastCreated *generated.Message
+ for i := 0; i < 3; i++ {
+ created, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: MessageTypeUser,
+ LLMData: map[string]interface{}{"index": i},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message %d: %v", i, err)
+ }
+ lastCreated = created
+ }
+
+ // Get the latest message
+ latest, err := db.GetLatestMessage(ctx, conv.ConversationID)
+ if err != nil {
+ t.Errorf("GetLatest() error = %v", err)
+ return
+ }
+
+ if latest.MessageID != lastCreated.MessageID {
+ t.Errorf("Expected latest message ID %s, got %s", lastCreated.MessageID, latest.MessageID)
+ }
+}
+
+func TestMessageService_Delete(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Create a test message
+ created, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: MessageTypeUser,
+ LLMData: map[string]string{"content": "test message"},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message: %v", err)
+ }
+
+ // Delete the message
+ err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+ return q.DeleteMessage(ctx, created.MessageID)
+ })
+ if err != nil {
+ t.Errorf("Delete() error = %v", err)
+ return
+ }
+
+ // Verify it's gone
+ _, err = db.GetMessageByID(ctx, created.MessageID)
+ if err == nil {
+ t.Error("Expected error when getting deleted message")
+ }
+}
+
+func TestMessageService_CountInConversation(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Initial count should be 0
+ var count int64
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountMessagesInConversation(ctx, conv.ConversationID)
+ return err
+ })
+ if err != nil {
+ t.Errorf("CountInConversation() error = %v", err)
+ return
+ }
+ if count != 0 {
+ t.Errorf("Expected initial count 0, got %d", count)
+ }
+
+ // Create test messages
+ for i := 0; i < 4; i++ {
+ _, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: MessageTypeUser,
+ LLMData: map[string]interface{}{"index": i},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message %d: %v", i, err)
+ }
+ }
+
+ // Count should now be 4
+ err = db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ count, err = q.CountMessagesInConversation(ctx, conv.ConversationID)
+ return err
+ })
+ if err != nil {
+ t.Errorf("CountInConversation() error = %v", err)
+ return
+ }
+ if count != 4 {
+ t.Errorf("Expected count 4, got %d", count)
+ }
+}
+
+func TestMessageService_CountByType(t *testing.T) {
+ db := setupTestDB(t)
+ defer db.Close()
+
+ // Using db directly instead of service
+ // Using db directly instead of service
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Create a test conversation
+ conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create test conversation: %v", err)
+ }
+
+ // Create messages of different types
+ msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeUser, MessageTypeTool, MessageTypeUser}
+ for i, msgType := range msgTypes {
+ _, err := db.CreateMessage(ctx, CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: msgType,
+ LLMData: map[string]interface{}{"index": i},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create test message %d: %v", i, err)
+ }
+ }
+
+ // Count user messages (should be 3)
+ userCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeUser)
+ if err != nil {
+ t.Errorf("CountByType() error = %v", err)
+ return
+ }
+ if userCount != 3 {
+ t.Errorf("Expected 3 user messages, got %d", userCount)
+ }
+
+ // Count agent messages (should be 1)
+ agentCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeAgent)
+ if err != nil {
+ t.Errorf("CountByType() error = %v", err)
+ return
+ }
+ if agentCount != 1 {
+ t.Errorf("Expected 1 agent message, got %d", agentCount)
+ }
+
+ // Count tool messages (should be 1)
+ toolCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeTool)
+ if err != nil {
+ t.Errorf("CountByType() error = %v", err)
+ return
+ }
+ if toolCount != 1 {
+ t.Errorf("Expected 1 tool message, got %d", toolCount)
+ }
+}
@@ -0,0 +1,351 @@
+// Package db provides database operations for the Shelley AI coding agent.
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// Pool is an SQLite connection pool.
+//
+// We deliberately minimize our use of database/sql machinery because
+// the semantics do not match SQLite well.
+//
+// Instead, we choose a single connection to use for writing (because
+// SQLite is single-writer) and use the rest as readers.
+type Pool struct {
+ db *sql.DB
+ writer chan *sql.Conn
+ readers chan *sql.Conn
+}
+
+func NewPool(dataSourceName string, readerCount int) (*Pool, error) {
+ if dataSourceName == ":memory:" {
+ return nil, fmt.Errorf(":memory: is not supported (because multiple conns are needed); use a temp file")
+ }
+ // TODO: a caller could override PRAGMA query_only.
+ // Consider opening two *sql.DBs, one configured as read-only,
+ // to ensure read-only transactions are always such.
+ db, err := sql.Open("sqlite", dataSourceName)
+ if err != nil {
+ return nil, fmt.Errorf("NewPool: %w", err)
+ }
+ numConns := readerCount + 1
+ if err := InitPoolDB(db, numConns); err != nil {
+ return nil, fmt.Errorf("NewPool: %w", err)
+ }
+
+ var conns []*sql.Conn
+ for i := 0; i < numConns; i++ {
+ conn, err := db.Conn(context.Background())
+ if err != nil {
+ db.Close()
+ return nil, fmt.Errorf("NewPool: %w", err)
+ }
+ conns = append(conns, conn)
+ }
+
+ p := &Pool{
+ db: db,
+ writer: make(chan *sql.Conn, 1),
+ readers: make(chan *sql.Conn, readerCount),
+ }
+ p.writer <- conns[0]
+ for _, conn := range conns[1:] {
+ if _, err := conn.ExecContext(context.Background(), "PRAGMA query_only=1;"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("NewPool query_only: %w", err)
+ }
+ p.readers <- conn
+ }
+
+ return p, nil
+}
+
+// InitPoolDB fixes the database/sql pool to a set of fixed connections.
+func InitPoolDB(db *sql.DB, numConns int) error {
+ db.SetMaxIdleConns(numConns)
+ db.SetMaxOpenConns(numConns)
+ db.SetConnMaxLifetime(-1)
+ db.SetConnMaxIdleTime(-1)
+
+ initQueries := []string{
+ "PRAGMA journal_mode=wal;",
+ "PRAGMA busy_timeout=1000;",
+ "PRAGMA foreign_keys=ON;",
+ }
+
+ var conns []*sql.Conn
+ for i := 0; i < numConns; i++ {
+ conn, err := db.Conn(context.Background())
+ if err != nil {
+ db.Close()
+ return fmt.Errorf("InitPoolDB: %w", err)
+ }
+ for _, q := range initQueries {
+ if _, err := conn.ExecContext(context.Background(), q); err != nil {
+ db.Close()
+ return fmt.Errorf("InitPoolDB %d: %w", i, err)
+ }
+ }
+ conns = append(conns, conn)
+ }
+ for _, conn := range conns {
+ if err := conn.Close(); err != nil {
+ db.Close()
+ return fmt.Errorf("InitPoolDB: %w", err)
+ }
+ }
+ return nil
+}
+
+func (p *Pool) Close() error {
+ return p.db.Close()
+}
+
+type ctxKeyType int
+
+// CtxKey is the context value key used to store the current *Tx or *Rx.
+// In general this should not be used, plumb the tx directly.
+// This code is here is used for an exception: the slog package.
+var CtxKey any = ctxKeyType(0)
+
+func checkNoTx(ctx context.Context, typ string) {
+ x := ctx.Value(CtxKey)
+ if x == nil {
+ return
+ }
+ orig := "unexpected"
+ switch x := x.(type) {
+ case *Tx:
+ orig = "Tx (" + x.caller + ")"
+ case *Rx:
+ orig = "Rx (" + x.caller + ")"
+ }
+ panic(typ + " inside " + orig)
+}
+
+// Exec executes a single statement outside of a transaction.
+// Useful in the rare case of PRAGMAs that cannot execute inside a tx,
+// such as PRAGMA wal_checkpoint.
+func (p *Pool) Exec(ctx context.Context, query string, args ...interface{}) error {
+ checkNoTx(ctx, "Tx")
+ var conn *sql.Conn
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("Pool.Exec: %w", ctx.Err())
+ case conn = <-p.writer:
+ }
+ var err error
+ defer func() {
+ p.writer <- conn
+ }()
+ _, err = conn.ExecContext(ctx, query, args...)
+ return wrapErr("pool.exec", err)
+}
+
+func (p *Pool) Tx(ctx context.Context, fn func(ctx context.Context, tx *Tx) error) error {
+ checkNoTx(ctx, "Tx")
+ var conn *sql.Conn
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("Tx: %w", ctx.Err())
+ case conn = <-p.writer:
+ }
+
+ // If the context is closed, we want BEGIN to succeed and then
+ // we roll it back later.
+ if _, err := conn.ExecContext(context.WithoutCancel(ctx), "BEGIN IMMEDIATE;"); err != nil {
+ if strings.Contains(err.Error(), "SQLITE_BUSY") {
+ p.writer <- conn
+ return fmt.Errorf("Tx begin: %w", err)
+ }
+ // unrecoverable error, this will lock everything up
+ return fmt.Errorf("Tx LEAK %w", err)
+ }
+ tx := &Tx{
+ Rx: &Rx{conn: conn, p: p, caller: callerOfCaller(1)},
+ Now: time.Now(),
+ }
+ tx.ctx = context.WithValue(ctx, CtxKey, tx)
+
+ var err error
+ defer func() {
+ if err == nil {
+ _, err = tx.conn.ExecContext(tx.ctx, "COMMIT;")
+ if err != nil {
+ err = fmt.Errorf("Tx: commit: %w", err)
+ }
+ }
+ if err != nil {
+ err = p.rollback(tx.ctx, "Tx", err, tx.conn)
+ // always return conn,
+ // either the entire database is closed or the conn is fine.
+ }
+ tx.p.writer <- conn
+ }()
+ if ctxErr := tx.ctx.Err(); ctxErr != nil {
+ return ctxErr // fast path for canceled context
+ }
+ err = fn(tx.ctx, tx)
+
+ return err
+}
+
+func (p *Pool) Rx(ctx context.Context, fn func(ctx context.Context, rx *Rx) error) error {
+ checkNoTx(ctx, "Rx")
+ var conn *sql.Conn
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case conn = <-p.readers:
+ }
+
+ // If the context is closed, we want BEGIN to succeed and then
+ // we roll it back later.
+ if _, err := conn.ExecContext(context.WithoutCancel(ctx), "BEGIN;"); err != nil {
+ if strings.Contains(err.Error(), "SQLITE_BUSY") {
+ p.readers <- conn
+ return fmt.Errorf("Rx begin: %w", err)
+ }
+ // an unrecoverable error, e.g. tx-inside-tx misuse or IOERR
+ return fmt.Errorf("Rx LEAK: %w", err)
+ }
+ rx := &Rx{conn: conn, p: p, caller: callerOfCaller(1)}
+ rx.ctx = context.WithValue(ctx, CtxKey, rx)
+
+ var err error
+ defer func() {
+ err = p.rollback(rx.ctx, "Rx", err, rx.conn)
+ // always return conn,
+ // either the entire database is closed or the conn is fine.
+ rx.p.readers <- conn
+ }()
+ if ctxErr := rx.ctx.Err(); ctxErr != nil {
+ return ctxErr // fast path for canceled context
+ }
+ err = fn(rx.ctx, rx)
+ return err
+}
+
+func (p *Pool) rollback(ctx context.Context, txType string, txErr error, conn *sql.Conn) error {
+ // Even if the context is cancelled,
+ // we still need to rollback to finish up the transaction.
+ _, err := conn.ExecContext(context.WithoutCancel(ctx), "ROLLBACK;")
+ if err != nil && !strings.Contains(err.Error(), "no transaction is active") {
+ // There are a few cases where an error during a transaction
+ // will be reported as a rollback error:
+ // https://sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction
+ // In good operation, we should never see any of these.
+ //
+ // TODO: confirm this check works on all sqlite drivers.
+ if !strings.Contains(err.Error(), "SQLITE_BUSY") {
+ conn.Close()
+ p.db.Close()
+ }
+ return fmt.Errorf("%s: %v: rollback failed: %w", txType, txErr, err)
+ }
+ return txErr
+}
+
+type Tx struct {
+ *Rx
+ Now time.Time
+}
+
+func (tx *Tx) Exec(query string, args ...interface{}) (sql.Result, error) {
+ res, err := tx.conn.ExecContext(tx.ctx, query, args...)
+ return res, wrapErr("exec", err)
+}
+
+type Rx struct {
+ ctx context.Context
+ conn *sql.Conn
+ p *Pool
+ caller string // for debugging
+}
+
+func (rx *Rx) Context() context.Context {
+ return rx.ctx
+}
+
+func (rx *Rx) Query(query string, args ...interface{}) (*sql.Rows, error) {
+ rows, err := rx.conn.QueryContext(rx.ctx, query, args...)
+ return rows, wrapErr("query", err)
+}
+
+func (rx *Rx) QueryRow(query string, args ...interface{}) *Row {
+ rows, err := rx.conn.QueryContext(rx.ctx, query, args...)
+ return &Row{err: err, rows: rows}
+}
+
+// Conn returns the underlying sql.Conn for use with external libraries like sqlc
+func (rx *Rx) Conn() *sql.Conn {
+ return rx.conn
+}
+
+// Row is equivalent to *sql.Row, but we provide a more useful error.
+type Row struct {
+ err error
+ rows *sql.Rows
+}
+
+func (r *Row) Scan(dest ...any) error {
+ if r.err != nil {
+ return wrapErr("QueryRow", r.err)
+ }
+
+ defer r.rows.Close()
+ if !r.rows.Next() {
+ if err := r.rows.Err(); err != nil {
+ return wrapErr("QueryRow.Scan", err)
+ }
+ return wrapErr("QueryRow.Scan", sql.ErrNoRows)
+ }
+ err := r.rows.Scan(dest...)
+ if err != nil {
+ return wrapErr("QueryRow.Scan", err)
+ }
+ return wrapErr("QueryRow.Scan", r.rows.Close())
+}
+
+func wrapErr(prefix string, err error) error {
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("%s: %s: %w", callerOfCaller(2), prefix, err)
+}
+
+func callerOfCaller(depth int) string {
+ caller := "unknown"
+ pc := make([]uintptr, 3)
+ const addedSkip = 3 // runtime.Callers, callerOfCaller, our caller (e.g. wrapErr or Rx)
+ if n := runtime.Callers(addedSkip+depth-1, pc[:]); n > 0 {
+ frames := runtime.CallersFrames(pc[:n])
+ frame, _ := frames.Next()
+ if frame.Function != "" {
+ caller = frame.Function
+ }
+ // This is a special case.
+ //
+ // We expect people to wrap the Tx/Rx objects
+ // in another domain-specific Tx/Rx object. That means
+ // they almost certainly have matching Tx/Rx methods,
+ // which aren't useful for debugging. So if we see that,
+ // we remove it.
+ if strings.HasSuffix(caller, ".Tx") || strings.HasSuffix(caller, ".Rx") {
+ frame, more := frames.Next()
+ if more && frame.Function != "" {
+ caller = frame.Function
+ }
+ }
+ }
+ if i := strings.LastIndexByte(caller, '/'); i >= 0 {
+ caller = caller[i+1:]
+ }
+ return caller
+}
@@ -0,0 +1,75 @@
+-- name: CreateConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
+VALUES (?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetConversation :one
+SELECT * FROM conversations
+WHERE conversation_id = ?;
+
+-- name: GetConversationBySlug :one
+SELECT * FROM conversations
+WHERE slug = ?;
+
+-- name: ListConversations :many
+SELECT * FROM conversations
+WHERE archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: ListArchivedConversations :many
+SELECT * FROM conversations
+WHERE archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: SearchConversations :many
+SELECT * FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: SearchArchivedConversations :many
+SELECT * FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: UpdateConversationSlug :one
+UPDATE conversations
+SET slug = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UpdateConversationTimestamp :exec
+UPDATE conversations
+SET updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?;
+
+-- name: DeleteConversation :exec
+DELETE FROM conversations
+WHERE conversation_id = ?;
+
+-- name: CountConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE;
+
+-- name: CountArchivedConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = TRUE;
+
+-- name: ArchiveConversation :one
+UPDATE conversations
+SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UnarchiveConversation :one
+UPDATE conversations
+SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UpdateConversationCwd :one
+UPDATE conversations
+SET cwd = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
@@ -0,0 +1,56 @@
+-- name: CreateMessage :one
+INSERT INTO messages (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, display_data)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetNextSequenceID :one
+SELECT COALESCE(MAX(sequence_id), 0) + 1
+FROM messages
+WHERE conversation_id = ?;
+
+-- name: GetMessage :one
+SELECT * FROM messages
+WHERE message_id = ?;
+
+-- name: ListMessages :many
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC;
+
+-- name: ListMessagesPaginated :many
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+LIMIT ? OFFSET ?;
+
+-- name: ListMessagesByType :many
+SELECT * FROM messages
+WHERE conversation_id = ? AND type = ?
+ORDER BY sequence_id ASC;
+
+-- name: GetLatestMessage :one
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id DESC
+LIMIT 1;
+
+-- name: DeleteMessage :exec
+DELETE FROM messages
+WHERE message_id = ?;
+
+-- name: DeleteConversationMessages :exec
+DELETE FROM messages
+WHERE conversation_id = ?;
+
+-- name: CountMessagesInConversation :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ?;
+
+-- name: CountMessagesByType :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ? AND type = ?;
+
+-- name: ListMessagesSince :many
+SELECT * FROM messages
+WHERE conversation_id = ? AND sequence_id > ?
+ORDER BY sequence_id ASC;
@@ -0,0 +1,22 @@
+-- Conversations table
+-- Each conversation represents a single chat session with the AI agent
+
+-- Create migrations tracking table
+CREATE TABLE migrations (
+ migration_number INTEGER PRIMARY KEY,
+ migration_name TEXT NOT NULL,
+ executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE conversations (
+ conversation_id TEXT PRIMARY KEY,
+ slug TEXT, -- human-readable identifier, can be null initially
+ user_initiated BOOLEAN NOT NULL DEFAULT TRUE, -- FALSE for subagent/tool conversations
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Partial unique index on slug (only for non-NULL values) for uniqueness and faster lookups
+CREATE UNIQUE INDEX idx_conversations_slug_unique ON conversations(slug) WHERE slug IS NOT NULL;
+-- Index on updated_at for ordering by recent activity
+CREATE INDEX idx_conversations_updated_at ON conversations(updated_at DESC);
@@ -0,0 +1,19 @@
+-- Messages table
+-- Each message is part of a conversation and can be from user, agent, or tool
+CREATE TABLE messages (
+ message_id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system')),
+ llm_data TEXT, -- JSON data sent to/from LLM
+ user_data TEXT, -- JSON data for UI display
+ usage_data TEXT, -- JSON data about token usage, etc.
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Index on conversation_id for efficient message retrieval
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+-- Index on conversation_id and created_at for chronological ordering
+CREATE INDEX idx_messages_conversation_created ON messages(conversation_id, created_at);
+-- Index on type for filtering by message type
+CREATE INDEX idx_messages_type ON messages(type);
@@ -0,0 +1,42 @@
+-- Add autoincrementing sequence_id to messages table for reliable ordering
+-- This eliminates timestamp collision issues when multiple messages are created simultaneously
+
+-- Create new table with sequence_id column
+CREATE TABLE messages_new (
+ message_id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL,
+ sequence_id INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system')),
+ llm_data TEXT, -- JSON data sent to/from LLM
+ user_data TEXT, -- JSON data for UI display
+ usage_data TEXT, -- JSON data about token usage, etc.
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Copy data from old table to new table with sequence_id based on created_at order
+-- Only run if the new table is empty (idempotent)
+INSERT INTO messages_new (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at)
+SELECT
+ message_id,
+ conversation_id,
+ ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at, message_id) as sequence_id,
+ type,
+ llm_data,
+ user_data,
+ usage_data,
+ created_at
+FROM messages
+WHERE NOT EXISTS (SELECT 1 FROM messages_new LIMIT 1);
+
+-- Replace old table with new table (only if we have data in the new table)
+-- Check if we need to do the table swap
+DROP TABLE IF EXISTS messages_old;
+ALTER TABLE messages RENAME TO messages_old;
+ALTER TABLE messages_new RENAME TO messages;
+DROP TABLE messages_old;
+
+-- Recreate indexes with sequence_id instead of created_at for ordering
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);
@@ -0,0 +1,30 @@
+-- Add 'error' to the message type check constraint
+-- This requires dropping and recreating the messages table with the new constraint
+-- SQLite doesn't support ALTER TABLE to modify CHECK constraints
+
+-- Step 1: Create a new messages table with the updated constraint
+CREATE TABLE messages_new (
+ message_id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL,
+ sequence_id INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system', 'error')),
+ llm_data TEXT, -- JSON data sent to/from LLM
+ user_data TEXT, -- JSON data for UI display
+ usage_data TEXT, -- JSON data about token usage, etc.
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old table to new table
+INSERT INTO messages_new SELECT * FROM messages;
+
+-- Step 3: Drop the old table
+DROP TABLE messages;
+
+-- Step 4: Rename the new table
+ALTER TABLE messages_new RENAME TO messages;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);
@@ -0,0 +1,4 @@
+-- Add display_data column to messages table for tool-specific UI rendering
+-- This allows us to separate what's sent to the LLM from what's displayed to the user
+
+ALTER TABLE messages ADD COLUMN display_data TEXT; -- JSON data for tool-specific display
@@ -0,0 +1,4 @@
+-- Add cwd (current working directory) column to conversations
+-- This allows each conversation to have its own working directory for tools
+
+ALTER TABLE conversations ADD COLUMN cwd TEXT;
@@ -0,0 +1,5 @@
+-- Add archived column to conversations
+ALTER TABLE conversations ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Index on archived for filtering
+CREATE INDEX idx_conversations_archived ON conversations(archived);
@@ -0,0 +1,97 @@
+module shelley.exe.dev
+
+go 1.25.5
+
+require (
+ github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
+ github.com/chromedp/chromedp v0.14.1
+ github.com/google/uuid v1.6.0
+ github.com/oklog/ulid/v2 v2.1.1
+ github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c
+ github.com/richardlehane/crock32 v1.0.1
+ github.com/samber/slog-http v1.8.2
+ github.com/sashabaranov/go-openai v1.41.1
+ go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a
+ golang.org/x/sync v0.19.0
+ mvdan.cc/sh/v3 v3.12.0
+ sketch.dev v0.0.33
+ tailscale.com v1.84.3
+)
+
+require (
+ cel.dev/expr v0.24.0 // indirect
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/bitfield/gotestdox v0.2.2 // indirect
+ github.com/cubicdaiya/gonp v1.0.4 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dnephin/pflag v1.0.7 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/fatih/structtag v1.2.0 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/google/cel-go v0.26.1 // indirect
+ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.7.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
+ github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
+ github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
+ github.com/pingcap/log v1.1.0 // indirect
+ github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/riza-io/grpc-go v0.2.0 // indirect
+ github.com/spf13/cobra v1.9.1 // indirect
+ github.com/spf13/pflag v1.0.7 // indirect
+ github.com/sqlc-dev/sqlc v1.30.0 // indirect
+ github.com/stoewer/go-strcase v1.2.0 // indirect
+ github.com/tetratelabs/wazero v1.9.0 // indirect
+ github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
+ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
+ go.opentelemetry.io/otel v1.37.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ golang.org/x/crypto v0.46.0 // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+ golang.org/x/mod v0.31.0 // indirect
+ golang.org/x/net v0.48.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
+ golang.org/x/tools v0.40.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+ google.golang.org/grpc v1.75.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ gotest.tools/gotestsum v1.13.0 // indirect
+ modernc.org/libc v1.66.3 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
+
+require (
+ github.com/chromedp/sysutil v1.1.0 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
+ golang.org/x/sys v0.39.0
+ golang.org/x/term v0.38.0 // indirect
+ modernc.org/sqlite v1.38.2
+)
+
+tool (
+ github.com/sqlc-dev/sqlc/cmd/sqlc
+ golang.org/x/tools/cmd/stringer
+ gotest.tools/gotestsum
+)
@@ -0,0 +1,272 @@
+cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
+cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
+github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TTENg=
+github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
+github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
+github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
+github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
+github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
+github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
+github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
+github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
+github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
+github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c h1:8TRxBMS/YsupXoOiGKHr9ZOXo+5DezGWPgBAhBHEHto=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/crock32 v1.0.1 h1:GV9EqtAr7RminQ8oGrDt3gYXkzDDPJ5fROaO1Mux14g=
+github.com/richardlehane/crock32 v1.0.1/go.mod h1:xUIlLABtHBgs1bNIBdUQR9F2xtRzS0TujtbR68hmEWU=
+github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
+github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/samber/slog-http v1.8.2 h1:4UJ5n+kw8BYo1pn+mu03M/DTqAZj6FFOawhLj8MYENk=
+github.com/samber/slog-http v1.8.2/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g=
+github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
+github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
+github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
+github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a h1:XqDi+8oE4eakFiXZXmQlsPaZTTdsPOy54jP3my6lIcU=
+go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a/go.mod h1:itQeLiwIYtXPJJEqdxRpOlS77LNv/quHjkyy+SaXrkw=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
+gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
+mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
+sketch.dev v0.0.33 h1:HbsXZt5NgRzlEQhWyzRtw7zf1r5t+0ek7xJihcklfPw=
+sketch.dev v0.0.33/go.mod h1:/IfRuZ0JiI0nVEx20JKAOCCcZh3dsqmjpjZJ4W2jLC0=
+tailscale.com v1.84.3 h1:Ur9LMedSgicwbqpy5xn7t49G8490/s6rqAJOk5Q5AYE=
+tailscale.com v1.84.3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=
@@ -0,0 +1,623 @@
+package ant
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "math/rand/v2"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/llm"
+)
+
+const (
+ DefaultModel = Claude45Sonnet
+ // See https://docs.anthropic.com/en/docs/about-claude/models/all-models for
+ // current maximums. There's currently a flag to enable 128k output (output-128k-2025-02-19)
+ DefaultMaxTokens = 8192
+ APIKeyEnv = "ANTHROPIC_API_KEY"
+ DefaultURL = "https://api.anthropic.com/v1/messages"
+)
+
+const (
+ Claude45Haiku = "claude-haiku-4-5-20251001"
+ Claude37Sonnet = "claude-3-7-sonnet-20250219"
+ Claude4Sonnet = "claude-sonnet-4-20250514"
+ Claude45Sonnet = "claude-sonnet-4-5-20250929"
+ Claude45Opus = "claude-opus-4-5-20251101"
+)
+
+// IsClaudeModel reports whether userName is a user-friendly Claude model.
+// It uses ClaudeModelName under the hood.
+func IsClaudeModel(userName string) bool {
+ return ClaudeModelName(userName) != ""
+}
+
+// ClaudeModelName returns the Anthropic Claude model name for userName.
+// It returns an empty string if userName is not a recognized Claude model.
+func ClaudeModelName(userName string) string {
+ switch userName {
+ case "claude", "sonnet":
+ return Claude45Sonnet
+ case "opus":
+ return Claude45Opus
+ default:
+ return ""
+ }
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+ model := s.Model
+ if model == "" {
+ model = DefaultModel
+ }
+
+ switch model {
+ case Claude37Sonnet, Claude4Sonnet, Claude45Sonnet:
+ return 200000
+ case Claude45Haiku:
+ return 200000
+ case Claude45Opus:
+ return 200000
+ default:
+ // Default for unknown models
+ return 200000
+ }
+}
+
+// HTTPRecorder is a callback for recording HTTP request/response data for debugging
+type HTTPRecorder func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration)
+
+// Service provides Claude completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ URL string // defaults to DefaultURL if empty
+ APIKey string // must be non-empty
+ Model string // defaults to DefaultModel if empty
+ MaxTokens int // defaults to DefaultMaxTokens if zero
+ DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+ HTTPRecorder HTTPRecorder // optional callback for recording HTTP requests/responses
+}
+
+var _ llm.Service = (*Service)(nil)
+
+type content struct {
+ // https://docs.anthropic.com/en/api/messages
+ ID string `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+
+ // Subtly, an empty string appears in tool results often, so we have
+ // to distinguish between empty string and no string.
+ // Underlying error looks like one of:
+ // "messages.46.content.0.tool_result.content.0.text.text: Field required""
+ // "messages.1.content.1.tool_use.text: Extra inputs are not permitted"
+ //
+ // I haven't found a super great source for the API, but
+ // https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts
+ // is somewhat acceptable but hard to read.
+ Text *string `json:"text,omitempty"`
+ MediaType string `json:"media_type,omitempty"` // for image
+ Source json.RawMessage `json:"source,omitempty"` // for image
+
+ // for thinking
+ Thinking string `json:"thinking,omitempty"`
+ Data string `json:"data,omitempty"` // for redacted_thinking or image
+ Signature string `json:"signature,omitempty"` // for thinking
+
+ // for tool_use
+ ToolName string `json:"name,omitempty"`
+ ToolInput json.RawMessage `json:"input,omitempty"`
+
+ // for tool_result
+ ToolUseID string `json:"tool_use_id,omitempty"`
+ ToolError bool `json:"is_error,omitempty"`
+ // note the recursive nature here; message looks like:
+ // {
+ // "role": "user",
+ // "content": [
+ // {
+ // "type": "tool_result",
+ // "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
+ // "content": [
+ // {"type": "text", "text": "15 degrees"},
+ // {
+ // "type": "image",
+ // "source": {
+ // "type": "base64",
+ // "media_type": "image/jpeg",
+ // "data": "/9j/4AAQSkZJRg...",
+ // }
+ // }
+ // ]
+ // }
+ // ]
+ //}
+ ToolResult []content `json:"content,omitempty"`
+
+ // timing information for tool_result; not sent to Claude
+ StartTime *time.Time `json:"-"`
+ EndTime *time.Time `json:"-"`
+
+ CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// message represents a message in the conversation.
+type message struct {
+ Role string `json:"role"`
+ Content []content `json:"content"`
+ ToolUse *toolUse `json:"tool_use,omitempty"` // use to control whether/which tool to use
+}
+
+// toolUse represents a tool use in the message content.
+type toolUse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// tool represents a tool available to Claude.
+type tool struct {
+ Name string `json:"name"`
+ // Type is used by the text editor tool; see
+ // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
+ Type string `json:"type,omitempty"`
+ Description string `json:"description,omitempty"`
+ InputSchema json.RawMessage `json:"input_schema,omitempty"`
+ CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// usage represents the billing and rate-limit usage.
+type usage struct {
+ InputTokens uint64 `json:"input_tokens"`
+ CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
+ OutputTokens uint64 `json:"output_tokens"`
+ CostUSD float64 `json:"cost_usd"`
+}
+
+func (u *usage) Add(other usage) {
+ u.InputTokens += other.InputTokens
+ u.CacheCreationInputTokens += other.CacheCreationInputTokens
+ u.CacheReadInputTokens += other.CacheReadInputTokens
+ u.OutputTokens += other.OutputTokens
+ u.CostUSD += other.CostUSD
+}
+
+// response represents the response from the message API.
+type response struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Role string `json:"role"`
+ Model string `json:"model"`
+ Content []content `json:"content"`
+ StopReason string `json:"stop_reason"`
+ StopSequence *string `json:"stop_sequence,omitempty"`
+ Usage usage `json:"usage"`
+}
+
+type toolChoice struct {
+ Type string `json:"type"`
+ Name string `json:"name,omitempty"`
+}
+
+// https://docs.anthropic.com/en/api/messages#body-system
+type systemContent struct {
+ Text string `json:"text,omitempty"`
+ Type string `json:"type,omitempty"`
+ CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// request represents the request payload for creating a message.
+type request struct {
+ Model string `json:"model"`
+ Messages []message `json:"messages"`
+ ToolChoice *toolChoice `json:"tool_choice,omitempty"`
+ MaxTokens int `json:"max_tokens"`
+ Tools []*tool `json:"tools,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ System []systemContent `json:"system,omitempty"`
+ Temperature float64 `json:"temperature,omitempty"`
+ TopK int `json:"top_k,omitempty"`
+ TopP float64 `json:"top_p,omitempty"`
+ StopSequences []string `json:"stop_sequences,omitempty"`
+
+ TokenEfficientToolUse bool `json:"-"` // DO NOT USE, broken on Anthropic's side as of 2025-02-28
+}
+
+func mapped[Slice ~[]E, E, T any](s Slice, f func(E) T) []T {
+ out := make([]T, len(s))
+ for i, v := range s {
+ out[i] = f(v)
+ }
+ return out
+}
+
+func inverted[K, V cmp.Ordered](m map[K]V) map[V]K {
+ inv := make(map[V]K)
+ for k, v := range m {
+ if _, ok := inv[v]; ok {
+ panic(fmt.Errorf("inverted map has multiple keys for value %v", v))
+ }
+ inv[v] = k
+ }
+ return inv
+}
+
+var (
+ fromLLMRole = map[llm.MessageRole]string{
+ llm.MessageRoleAssistant: "assistant",
+ llm.MessageRoleUser: "user",
+ }
+ toLLMRole = inverted(fromLLMRole)
+
+ fromLLMContentType = map[llm.ContentType]string{
+ llm.ContentTypeText: "text",
+ llm.ContentTypeThinking: "thinking",
+ llm.ContentTypeRedactedThinking: "redacted_thinking",
+ llm.ContentTypeToolUse: "tool_use",
+ llm.ContentTypeToolResult: "tool_result",
+ }
+ toLLMContentType = inverted(fromLLMContentType)
+
+ fromLLMToolChoiceType = map[llm.ToolChoiceType]string{
+ llm.ToolChoiceTypeAuto: "auto",
+ llm.ToolChoiceTypeAny: "any",
+ llm.ToolChoiceTypeNone: "none",
+ llm.ToolChoiceTypeTool: "tool",
+ }
+
+ toLLMStopReason = map[string]llm.StopReason{
+ "stop_sequence": llm.StopReasonStopSequence,
+ "max_tokens": llm.StopReasonMaxTokens,
+ "end_turn": llm.StopReasonEndTurn,
+ "tool_use": llm.StopReasonToolUse,
+ "refusal": llm.StopReasonRefusal,
+ }
+)
+
+func fromLLMCache(c bool) json.RawMessage {
+ if !c {
+ return nil
+ }
+ return json.RawMessage(`{"type":"ephemeral"}`)
+}
+
+func fromLLMContent(c llm.Content) content {
+ var toolResult []content
+ if len(c.ToolResult) > 0 {
+ toolResult = make([]content, len(c.ToolResult))
+ for i, tr := range c.ToolResult {
+ // For image content inside a tool_result, we need to map it to "image" type
+ if tr.MediaType != "" && tr.MediaType == "image/jpeg" || tr.MediaType == "image/png" {
+ // Format as an image for Claude
+ toolResult[i] = content{
+ Type: "image",
+ Source: json.RawMessage(fmt.Sprintf(`{"type":"base64","media_type":"%s","data":"%s"}`,
+ tr.MediaType, tr.Data)),
+ }
+ } else {
+ toolResult[i] = fromLLMContent(tr)
+ }
+ }
+ }
+
+ d := content{
+ Type: fromLLMContentType[c.Type],
+ CacheControl: fromLLMCache(c.Cache),
+ }
+
+ // Set fields based on content type to avoid sending invalid fields
+ switch c.Type {
+ case llm.ContentTypeText:
+ // Images are represented as text with MediaType and Data
+ if c.MediaType != "" {
+ d.Type = "image"
+ d.Source = json.RawMessage(fmt.Sprintf(`{"type":"base64","media_type":"%s","data":"%s"}`,
+ c.MediaType, c.Data))
+ } else {
+ d.Text = &c.Text
+ }
+ case llm.ContentTypeThinking:
+ d.Thinking = c.Thinking
+ d.Signature = c.Signature
+ case llm.ContentTypeRedactedThinking:
+ d.Data = c.Data
+ d.Signature = c.Signature
+ case llm.ContentTypeToolUse:
+ d.ID = c.ID
+ d.ToolName = c.ToolName
+ d.ToolInput = c.ToolInput
+ case llm.ContentTypeToolResult:
+ d.ToolUseID = c.ToolUseID
+ d.ToolError = c.ToolError
+ d.ToolResult = toolResult
+ }
+
+ return d
+}
+
+func fromLLMToolUse(tu *llm.ToolUse) *toolUse {
+ if tu == nil {
+ return nil
+ }
+ return &toolUse{
+ ID: tu.ID,
+ Name: tu.Name,
+ }
+}
+
+func fromLLMMessage(msg llm.Message) message {
+ return message{
+ Role: fromLLMRole[msg.Role],
+ Content: mapped(msg.Content, fromLLMContent),
+ ToolUse: fromLLMToolUse(msg.ToolUse),
+ }
+}
+
+func fromLLMToolChoice(tc *llm.ToolChoice) *toolChoice {
+ if tc == nil {
+ return nil
+ }
+ return &toolChoice{
+ Type: fromLLMToolChoiceType[tc.Type],
+ Name: tc.Name,
+ }
+}
+
+func fromLLMTool(t *llm.Tool) *tool {
+ return &tool{
+ Name: t.Name,
+ Type: t.Type,
+ Description: t.Description,
+ InputSchema: t.InputSchema,
+ CacheControl: fromLLMCache(t.Cache),
+ }
+}
+
+func fromLLMSystem(s llm.SystemContent) systemContent {
+ return systemContent{
+ Text: s.Text,
+ Type: s.Type,
+ CacheControl: fromLLMCache(s.Cache),
+ }
+}
+
+func (s *Service) fromLLMRequest(r *llm.Request) *request {
+ return &request{
+ Model: cmp.Or(s.Model, DefaultModel),
+ Messages: mapped(r.Messages, fromLLMMessage),
+ MaxTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
+ ToolChoice: fromLLMToolChoice(r.ToolChoice),
+ Tools: mapped(r.Tools, fromLLMTool),
+ System: mapped(r.System, fromLLMSystem),
+ }
+}
+
+func toLLMUsage(u usage) llm.Usage {
+ return llm.Usage{
+ InputTokens: u.InputTokens,
+ CacheCreationInputTokens: u.CacheCreationInputTokens,
+ CacheReadInputTokens: u.CacheReadInputTokens,
+ OutputTokens: u.OutputTokens,
+ CostUSD: u.CostUSD,
+ }
+}
+
+func toLLMContent(c content) llm.Content {
+ // Convert toolResult from []content to []llm.Content
+ var toolResultContents []llm.Content
+ if len(c.ToolResult) > 0 {
+ toolResultContents = make([]llm.Content, len(c.ToolResult))
+ for i, tr := range c.ToolResult {
+ toolResultContents[i] = toLLMContent(tr)
+ }
+ }
+
+ ret := llm.Content{
+ ID: c.ID,
+ Type: toLLMContentType[c.Type],
+ MediaType: c.MediaType,
+ Thinking: c.Thinking,
+ Data: c.Data,
+ Signature: c.Signature,
+ ToolName: c.ToolName,
+ ToolInput: c.ToolInput,
+ ToolUseID: c.ToolUseID,
+ ToolError: c.ToolError,
+ ToolResult: toolResultContents,
+ }
+ if c.Text != nil {
+ ret.Text = *c.Text
+ }
+ return ret
+}
+
+func toLLMResponse(r *response) *llm.Response {
+ return &llm.Response{
+ ID: r.ID,
+ Type: r.Type,
+ Role: toLLMRole[r.Role],
+ Model: r.Model,
+ Content: mapped(r.Content, toLLMContent),
+ StopReason: toLLMStopReason[r.StopReason],
+ StopSequence: r.StopSequence,
+ Usage: toLLMUsage(r.Usage),
+ }
+}
+
+// Do sends a request to Anthropic.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+ startTime := time.Now()
+ request := s.fromLLMRequest(ir)
+ var payload []byte
+ var err error
+ if s.DumpLLM || testing.Testing() {
+ payload, err = json.MarshalIndent(request, "", " ")
+ } else {
+ payload, err = json.Marshal(request)
+ payload = append(payload, '\n')
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if false {
+ fmt.Printf("claude request payload:\n%s\n", payload)
+ }
+
+ backoff := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute}
+ largerMaxTokens := false
+ var partialUsage usage
+
+ url := cmp.Or(s.URL, DefaultURL)
+ httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+
+ // For recording the last attempt's response
+ var lastResponseBody []byte
+ var lastStatusCode int
+ var finalErr error
+ defer func() {
+ if s.HTTPRecorder != nil {
+ s.HTTPRecorder(url, payload, lastResponseBody, lastStatusCode, finalErr, time.Since(startTime))
+ }
+ }()
+
+ // retry loop
+ var errs error // accumulated errors across all attempts
+ for attempts := 0; ; attempts++ {
+ if attempts > 10 {
+ return nil, fmt.Errorf("anthropic request failed after %d attempts: %w", attempts, errs)
+ }
+ if attempts > 0 {
+ sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+ slog.WarnContext(ctx, "anthropic request sleep before retry", "sleep", sleep, "attempts", attempts)
+ time.Sleep(sleep)
+ }
+ if s.DumpLLM {
+ if err := llm.DumpToFile("request", url, payload); err != nil {
+ slog.WarnContext(ctx, "failed to dump request to file", "error", err)
+ }
+ }
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
+ if err != nil {
+ return nil, errors.Join(errs, err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-API-Key", s.APIKey)
+ req.Header.Set("Anthropic-Version", "2023-06-01")
+
+ var features []string
+ if request.TokenEfficientToolUse {
+ features = append(features, "token-efficient-tool-use-2025-02-19")
+ }
+ if largerMaxTokens {
+ features = append(features, "output-128k-2025-02-19")
+ request.MaxTokens = 128 * 1024
+ }
+ if len(features) > 0 {
+ req.Header.Set("anthropic-beta", strings.Join(features, ","))
+ }
+
+ resp, err := httpc.Do(req)
+ if err != nil {
+ // Don't retry httprr cache misses
+ if strings.Contains(err.Error(), "cached HTTP response not found") {
+ return nil, err
+ }
+ errs = errors.Join(errs, err)
+ continue
+ }
+ buf, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ errs = errors.Join(errs, err)
+ continue
+ }
+
+ // Record response for HTTPRecorder callback
+ lastResponseBody = buf
+ lastStatusCode = resp.StatusCode
+
+ switch {
+ case resp.StatusCode == http.StatusOK:
+ if s.DumpLLM {
+ if err := llm.DumpToFile("response", "", buf); err != nil {
+ slog.WarnContext(ctx, "failed to dump response to file", "error", err)
+ }
+ }
+ var response response
+ err = json.NewDecoder(bytes.NewReader(buf)).Decode(&response)
+ if err != nil {
+ return nil, errors.Join(errs, err)
+ }
+ if response.StopReason == "max_tokens" && !largerMaxTokens {
+ slog.InfoContext(ctx, "anthropic_retrying_with_larger_tokens", "message", "Retrying Anthropic API call with larger max tokens size")
+ // Retry with more output tokens.
+ largerMaxTokens = true
+ response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
+ partialUsage = response.Usage
+ continue
+ }
+
+ // Calculate and set the cost_usd field
+ if largerMaxTokens {
+ response.Usage.Add(partialUsage)
+ }
+ response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
+
+ endTime := time.Now()
+ result := toLLMResponse(&response)
+ result.StartTime = &startTime
+ result.EndTime = &endTime
+ return result, nil
+ case resp.StatusCode >= 500 && resp.StatusCode < 600:
+ // server error, retry
+ slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+ errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+ finalErr = errs
+ continue
+ case resp.StatusCode == 429:
+ // rate limited, retry
+ slog.WarnContext(ctx, "anthropic_request_rate_limited", "response", string(buf), "url", url, "model", s.Model)
+ errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+ finalErr = errs
+ continue
+ case resp.StatusCode >= 400 && resp.StatusCode < 500:
+ // some other 400, probably unrecoverable
+ slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+ return nil, errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+ default:
+ // ...retry, I guess?
+ slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+ errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+ finalErr = errs
+ continue
+ }
+ }
+}
+
+// For debugging only, Claude can definitely handle the full patch tool.
+// func (s *Service) UseSimplifiedPatch() bool {
+// return true
+// }
+
+// ConfigDetails returns configuration information for logging
+func (s *Service) ConfigDetails() map[string]string {
+ model := cmp.Or(s.Model, DefaultModel)
+ url := cmp.Or(s.URL, DefaultURL)
+ return map[string]string{
+ "url": url,
+ "model": model,
+ "has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+ }
+}
@@ -0,0 +1,78 @@
+package ant
+
+import (
+ "encoding/json"
+ "testing"
+
+ "shelley.exe.dev/llm"
+)
+
+func TestAnthropicImageToolResult(t *testing.T) {
+ // Create a tool result with both text and image content
+ textContent := llm.Content{
+ Type: llm.ContentTypeText,
+ Text: "15 degrees",
+ }
+
+ imageContent := llm.Content{
+ Type: llm.ContentTypeText, // Will be mapped to "image" in Anthropic format
+ MediaType: "image/jpeg",
+ Data: "/9j/4AAQSkZJRg...", // Shortened base64 encoded image
+ }
+
+ toolResult := llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "toolu_01A09q90qw90lq917835lq9",
+ ToolResult: []llm.Content{textContent, imageContent},
+ }
+
+ // Convert to Anthropic format
+ anthropicContent := fromLLMContent(toolResult)
+
+ // Check the type
+ if anthropicContent.Type != "tool_result" {
+ t.Errorf("Expected type to be 'tool_result', got '%s'", anthropicContent.Type)
+ }
+
+ // Check the tool_use_id
+ if anthropicContent.ToolUseID != "toolu_01A09q90qw90lq917835lq9" {
+ t.Errorf("Expected tool_use_id to be 'toolu_01A09q90qw90lq917835lq9', got '%s'", anthropicContent.ToolUseID)
+ }
+
+ // Check that we have two content items in the tool result
+ if len(anthropicContent.ToolResult) != 2 {
+ t.Errorf("Expected 2 content items, got %d", len(anthropicContent.ToolResult))
+ }
+
+ // Check that the first item is text
+ if anthropicContent.ToolResult[0].Type != "text" {
+ t.Errorf("Expected first content type to be 'text', got '%s'", anthropicContent.ToolResult[0].Type)
+ }
+
+ if *anthropicContent.ToolResult[0].Text != "15 degrees" {
+ t.Errorf("Expected first content text to be '15 degrees', got '%s'", *anthropicContent.ToolResult[0].Text)
+ }
+
+ // Check that the second item is an image
+ if anthropicContent.ToolResult[1].Type != "image" {
+ t.Errorf("Expected second content type to be 'image', got '%s'", anthropicContent.ToolResult[1].Type)
+ }
+
+ // Check that the image source contains the expected format
+ var source map[string]any
+ if err := json.Unmarshal(anthropicContent.ToolResult[1].Source, &source); err != nil {
+ t.Errorf("Failed to unmarshal image source: %v", err)
+ }
+
+ if source["type"] != "base64" {
+ t.Errorf("Expected source type to be 'base64', got '%s'", source["type"])
+ }
+
+ if source["media_type"] != "image/jpeg" {
+ t.Errorf("Expected media_type to be 'image/jpeg', got '%s'", source["media_type"])
+ }
+
+ if source["data"] != "/9j/4AAQSkZJRg..." {
+ t.Errorf("Expected data to be '/9j/4AAQSkZJRg...', got '%s'", source["data"])
+ }
+}
@@ -0,0 +1,94 @@
+package ant
+
+import (
+ "encoding/json"
+ "testing"
+
+ "shelley.exe.dev/llm"
+)
+
+// TestTextContentNoExtraFields verifies that text content doesn't include fields from other content types
+func TestTextContentNoExtraFields(t *testing.T) {
+ tests := []struct {
+ name string
+ content llm.Content
+ allowedFields map[string]bool
+ }{
+ {
+ name: "text content",
+ content: llm.Content{
+ Type: llm.ContentTypeText,
+ Text: "Hello world",
+ },
+ allowedFields: map[string]bool{
+ "type": true,
+ "text": true,
+ },
+ },
+ {
+ name: "tool_use content",
+ content: llm.Content{
+ Type: llm.ContentTypeToolUse,
+ ID: "toolu_123",
+ ToolName: "bash",
+ ToolInput: json.RawMessage(`{"command":"ls"}`),
+ },
+ allowedFields: map[string]bool{
+ "type": true,
+ "id": true,
+ "name": true,
+ "input": true,
+ },
+ },
+ {
+ name: "tool_result content",
+ content: llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "toolu_123",
+ ToolResult: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "result"},
+ },
+ },
+ allowedFields: map[string]bool{
+ "type": true,
+ "tool_use_id": true,
+ "content": true,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ antContent := fromLLMContent(tt.content)
+ jsonBytes, err := json.Marshal(antContent)
+ if err != nil {
+ t.Fatalf("failed to marshal content: %v", err)
+ }
+
+ var result map[string]interface{}
+ if err := json.Unmarshal(jsonBytes, &result); err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ // Check that only allowed fields are present
+ for field := range result {
+ if !tt.allowedFields[field] {
+ t.Errorf("unexpected field %q in %s content: %s", field, tt.name, string(jsonBytes))
+ }
+ }
+
+ // Check that all required fields are present
+ for field := range tt.allowedFields {
+ if _, ok := result[field]; !ok && field != "cache_control" {
+ // cache_control is optional, so we don't require it
+ if field != "content" || tt.content.Type == llm.ContentTypeToolResult {
+ // Only check for content field if it's a tool_result
+ if field == "content" && tt.content.Type == llm.ContentTypeToolResult {
+ t.Errorf("missing required field %q in %s content: %s", field, tt.name, string(jsonBytes))
+ }
+ }
+ }
+ }
+ })
+ }
+}
@@ -0,0 +1,656 @@
+package conversation
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "math/rand/v2"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+ "github.com/richardlehane/crock32"
+ "shelley.exe.dev/llm"
+ "sketch.dev/skribe"
+)
+
+type Listener interface {
+ // TODO: Content is leaking an anthropic API; should we avoid it?
+ // TODO: Where should we include start/end time and usage?
+ OnToolCall(ctx context.Context, convo *Convo, toolCallID, toolName string, toolInput json.RawMessage, content llm.Content)
+ OnToolResult(ctx context.Context, convo *Convo, toolCallID, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error)
+ OnRequest(ctx context.Context, convo *Convo, requestID string, msg *llm.Message)
+ OnResponse(ctx context.Context, convo *Convo, requestID string, msg *llm.Response)
+}
+
+type NoopListener struct{}
+
+func (n *NoopListener) OnToolCall(ctx context.Context, convo *Convo, id, toolName string, toolInput json.RawMessage, content llm.Content) {
+}
+
+func (n *NoopListener) OnToolResult(ctx context.Context, convo *Convo, id, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) {
+}
+
+func (n *NoopListener) OnResponse(ctx context.Context, convo *Convo, id string, msg *llm.Response) {
+}
+func (n *NoopListener) OnRequest(ctx context.Context, convo *Convo, id string, msg *llm.Message) {}
+
+var ErrDoNotRespond = errors.New("do not respond")
+
+// A Convo is a managed conversation with Claude.
+// It automatically manages the state of the conversation,
+// including appending messages send/received,
+// calling tools and sending their results,
+// tracking usage, etc.
+//
+// Exported fields must not be altered concurrently with calling any method on Convo.
+// Typical usage is to configure a Convo once before using it.
+type Convo struct {
+ // ID is a unique ID for the conversation
+ ID string
+ // Ctx is the context for the entire conversation.
+ Ctx context.Context
+ // Service is the LLM service to use.
+ Service llm.Service
+ // Tools are the tools available during the conversation.
+ Tools []*llm.Tool
+ // SystemPrompt is the system prompt for the conversation.
+ SystemPrompt string
+ // PromptCaching indicates whether to use Anthropic's prompt caching.
+ // See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation
+ // for the documentation. At request send time, we set the cache_control field on the
+ // last message. We also cache the system prompt.
+ // Default: true.
+ PromptCaching bool
+ // ToolUseOnly indicates whether Claude may only use tools during this conversation.
+ // TODO: add more fine-grained control over tool use?
+ ToolUseOnly bool
+ // Parent is the parent conversation, if any.
+ // It is non-nil for "subagent" calls.
+ // It is set automatically when calling SubConvo,
+ // and usually should not be set manually.
+ Parent *Convo
+ // Budget is the budget for this conversation (and all sub-conversations).
+ // The Conversation DOES NOT automatically enforce the budget.
+ // It is up to the caller to call OverBudget() as appropriate.
+ Budget Budget
+ // Hidden indicates that the output of this conversation should be hidden in the UI.
+ // This is useful for subconversations that can generate noisy, uninteresting output.
+ Hidden bool
+ // ExtraData is extra data to make available to all tool calls.
+ ExtraData map[string]any
+
+ // messages tracks the messages so far in the conversation.
+ messages []llm.Message
+
+ // Listener receives messages being sent.
+ Listener Listener
+
+ toolUseCancelMu sync.Mutex
+ toolUseCancel map[string]context.CancelCauseFunc
+
+ // Protects usage. This is used for subconversations (that share part of CumulativeUsage) as well.
+ mu *sync.Mutex
+ // usage tracks usage for this conversation and all sub-conversations.
+ usage *CumulativeUsage
+ // lastUsage tracks the usage from the most recent API call
+ lastUsage llm.Usage
+}
+
+// newConvoID generates a new 8-byte random id.
+// The uniqueness/collision requirements here are very low.
+// They are not global identifiers,
+// just enough to distinguish different convos in a single session.
+func newConvoID() string {
+ u1 := rand.Uint32()
+ s := crock32.Encode(uint64(u1))
+ if len(s) < 7 {
+ s += strings.Repeat("0", 7-len(s))
+ }
+ return s[:3] + "-" + s[3:]
+}
+
+// New creates a new conversation with Claude with sensible defaults.
+// ctx is the context for the entire conversation.
+func New(ctx context.Context, srv llm.Service, usage *CumulativeUsage) *Convo {
+ id := newConvoID()
+ if usage == nil {
+ usage = newUsage()
+ }
+ return &Convo{
+ Ctx: skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
+ Service: srv,
+ PromptCaching: true,
+ usage: usage,
+ Listener: &NoopListener{},
+ ID: id,
+ toolUseCancel: map[string]context.CancelCauseFunc{},
+ mu: &sync.Mutex{},
+ }
+}
+
+// SubConvo creates a sub-conversation with the same configuration as the parent conversation.
+// (This propagates context for cancellation, HTTP client, API key, etc.)
+// The sub-conversation shares no messages with the parent conversation.
+// It does not inherit tools from the parent conversation.
+func (c *Convo) SubConvo() *Convo {
+ id := newConvoID()
+ return &Convo{
+ Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
+ Service: c.Service,
+ PromptCaching: c.PromptCaching,
+ Parent: c,
+ // For convenience, sub-convo usage shares tool uses map with parent,
+ // all other fields separate, propagated in AddResponse
+ usage: newUsageWithSharedToolUses(c.usage),
+ mu: c.mu,
+ Listener: c.Listener,
+ ID: id,
+ toolUseCancel: map[string]context.CancelCauseFunc{},
+ // Do not copy Budget. Each budget is independent,
+ // and OverBudget checks whether any ancestor is over budget.
+ }
+}
+
+func (c *Convo) SubConvoWithHistory() *Convo {
+ id := newConvoID()
+ return &Convo{
+ Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
+ Service: c.Service,
+ PromptCaching: c.PromptCaching,
+ Parent: c,
+ // For convenience, sub-convo usage shares tool uses map with parent,
+ // all other fields separate, propagated in AddResponse
+ usage: newUsageWithSharedToolUses(c.usage),
+ mu: c.mu,
+ Listener: c.Listener,
+ ID: id,
+ // Do not copy Budget. Each budget is independent,
+ // and OverBudget checks whether any ancestor is over budget.
+ messages: slices.Clone(c.messages),
+ }
+}
+
+// Depth reports how many "sub-conversations" deep this conversation is.
+// That it, it walks up parents until it finds a root.
+func (c *Convo) Depth() int {
+ x := c
+ var depth int
+ for x.Parent != nil {
+ x = x.Parent
+ depth++
+ }
+ return depth
+}
+
+// SendUserTextMessage sends a text message to the LLM in this conversation.
+// otherContents contains additional contents to send with the message, usually tool results.
+func (c *Convo) SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) {
+ contents := slices.Clone(otherContents)
+ if s != "" {
+ contents = append(contents, llm.Content{Type: llm.ContentTypeText, Text: s})
+ }
+ msg := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: contents,
+ }
+ return c.SendMessage(msg)
+}
+
+func (c *Convo) messageRequest(msg llm.Message) *llm.Request {
+ system := []llm.SystemContent{}
+ if c.SystemPrompt != "" {
+ d := llm.SystemContent{Type: "text", Text: c.SystemPrompt}
+ if c.PromptCaching {
+ d.Cache = true
+ }
+ system = []llm.SystemContent{d}
+ }
+
+ // Claude is happy to return an empty response in response to our Done() call,
+ // and, if so, you'll see something like:
+ // API request failed with status 400 Bad Request
+ // {"type":"error","error": {"type":"invalid_request_error",
+ // "message":"messages.5: all messages must have non-empty content except for the optional final assistant message"}}
+ // So, we filter out those empty messages.
+ var nonEmptyMessages []llm.Message
+ for _, m := range c.messages {
+ if len(m.Content) > 0 {
+ nonEmptyMessages = append(nonEmptyMessages, m)
+ }
+ }
+
+ // Also validate the new message being sent - don't add it if empty
+ messagesToSend := nonEmptyMessages
+ if len(msg.Content) > 0 {
+ messagesToSend = append(messagesToSend, msg)
+ }
+
+ mr := &llm.Request{
+ Messages: messagesToSend,
+ System: system,
+ Tools: c.Tools,
+ }
+ if c.ToolUseOnly {
+ mr.ToolChoice = &llm.ToolChoice{Type: llm.ToolChoiceTypeAny}
+ }
+ return mr
+}
+
+func (c *Convo) findTool(name string) (*llm.Tool, error) {
+ for _, tool := range c.Tools {
+ if tool.Name == name {
+ return tool, nil
+ }
+ }
+ return nil, fmt.Errorf("tool %q not found", name)
+}
+
+// insertMissingToolResults adds error results for tool uses that were requested
+// but not included in the message, which can happen in error paths like "out of budget."
+// We only insert these if there were no tool responses at all, since an incorrect
+// number of tool results would be a programmer error. Mutates inputs.
+func (c *Convo) insertMissingToolResults(mr *llm.Request, msg *llm.Message) {
+ if len(mr.Messages) < 2 {
+ return
+ }
+ prev := mr.Messages[len(mr.Messages)-2]
+ var toolUsePrev int
+ for _, c := range prev.Content {
+ if c.Type == llm.ContentTypeToolUse {
+ toolUsePrev++
+ }
+ }
+ if toolUsePrev == 0 {
+ return
+ }
+ var toolUseCurrent int
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult {
+ toolUseCurrent++
+ }
+ }
+ if toolUseCurrent != 0 {
+ return
+ }
+ var prefix []llm.Content
+ for _, part := range prev.Content {
+ if part.Type != llm.ContentTypeToolUse {
+ continue
+ }
+ content := llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: part.ID,
+ ToolError: true,
+ ToolResult: []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: "not executed; retry possible",
+ }},
+ }
+ prefix = append(prefix, content)
+ }
+ msg.Content = append(prefix, msg.Content...)
+ mr.Messages[len(mr.Messages)-1].Content = msg.Content
+ slog.DebugContext(c.Ctx, "inserted missing tool results")
+}
+
+// SendMessage sends a message to Claude.
+// The conversation records (internally) all messages succesfully sent and received.
+func (c *Convo) SendMessage(msg llm.Message) (*llm.Response, error) {
+ id := ulid.Make().String()
+ mr := c.messageRequest(msg)
+ var lastMessage *llm.Message
+ if c.PromptCaching {
+ lastMessage = &mr.Messages[len(mr.Messages)-1]
+ if len(lastMessage.Content) > 0 {
+ lastMessage.Content[len(lastMessage.Content)-1].Cache = true
+ }
+ }
+ defer func() {
+ if lastMessage == nil {
+ return
+ }
+ if len(lastMessage.Content) > 0 {
+ lastMessage.Content[len(lastMessage.Content)-1].Cache = false
+ }
+ }()
+ c.insertMissingToolResults(mr, &msg)
+ c.Listener.OnRequest(c.Ctx, c, id, &msg)
+
+ startTime := time.Now()
+ resp, err := c.Service.Do(c.Ctx, mr)
+ if resp != nil {
+ resp.StartTime = &startTime
+ endTime := time.Now()
+ resp.EndTime = &endTime
+ }
+
+ if err != nil {
+ c.Listener.OnResponse(c.Ctx, c, id, nil)
+ return nil, err
+ }
+ c.messages = append(c.messages, msg, resp.ToMessage())
+ // Propagate usage to all ancestors (including us).
+ for x := c; x != nil; x = x.Parent {
+ x.usage.Add(resp.Usage)
+ // Store the most recent usage (only on the current conversation, not ancestors)
+ if x == c {
+ x.lastUsage = resp.Usage
+ }
+ }
+ c.Listener.OnResponse(c.Ctx, c, id, resp)
+ return resp, err
+}
+
+type toolCallInfoKeyType string
+
+var toolCallInfoKey toolCallInfoKeyType
+
+type ToolCallInfo struct {
+ ToolUseID string
+}
+
+func ToolCallInfoFromContext(ctx context.Context) ToolCallInfo {
+ v := ctx.Value(toolCallInfoKey)
+ i, _ := v.(ToolCallInfo)
+ return i
+}
+
+func (c *Convo) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
+ if resp.StopReason != llm.StopReasonToolUse {
+ return nil, nil
+ }
+ var toolResults []llm.Content
+
+ for _, part := range resp.Content {
+ if part.Type != llm.ContentTypeToolUse {
+ continue
+ }
+ c.incrementToolUse(part.ToolName)
+
+ content := llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: part.ID,
+ }
+
+ content.ToolError = true
+ content.ToolResult = []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: "user canceled this tool_use",
+ }}
+ toolResults = append(toolResults, content)
+ }
+ return toolResults, nil
+}
+
+// GetID returns the conversation ID
+func (c *Convo) GetID() string {
+ return c.ID
+}
+
+func (c *Convo) CancelToolUse(toolUseID string, err error) error {
+ c.toolUseCancelMu.Lock()
+ defer c.toolUseCancelMu.Unlock()
+ cancel, ok := c.toolUseCancel[toolUseID]
+ if !ok {
+ return fmt.Errorf("cannot cancel %s: no cancel function registered for this tool_use_id. All I have is %+v", toolUseID, c.toolUseCancel)
+ }
+ delete(c.toolUseCancel, toolUseID)
+ cancel(err)
+ return nil
+}
+
+func (c *Convo) newToolUseContext(ctx context.Context, toolUseID string) (context.Context, context.CancelFunc) {
+ c.toolUseCancelMu.Lock()
+ defer c.toolUseCancelMu.Unlock()
+ ctx, cancel := context.WithCancelCause(ctx)
+ c.toolUseCancel[toolUseID] = cancel
+ return ctx, func() { c.CancelToolUse(toolUseID, nil) }
+}
+
+// ToolResultContents runs all tool uses requested by the response and returns their results.
+// Cancelling ctx will cancel any running tool calls.
+// The boolean return value indicates whether any of the executed tools should end the turn.
+func (c *Convo) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error) {
+ if resp.StopReason != llm.StopReasonToolUse {
+ return nil, false, nil
+ }
+ // Extract all tool calls from the response, call the tools, and gather the results.
+ var wg sync.WaitGroup
+ toolResultC := make(chan llm.Content, len(resp.Content))
+
+ endsTurn := false
+ for _, part := range resp.Content {
+ if part.Type != llm.ContentTypeToolUse {
+ continue
+ }
+ tool, err := c.findTool(part.ToolName)
+ if err == nil && tool.EndsTurn {
+ endsTurn = true
+ }
+ c.incrementToolUse(part.ToolName)
+ startTime := time.Now()
+
+ c.Listener.OnToolCall(ctx, c, part.ID, part.ToolName, part.ToolInput, llm.Content{
+ Type: llm.ContentTypeToolUse,
+ ToolUseID: part.ID,
+ ToolUseStartTime: &startTime,
+ })
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ content := llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: part.ID,
+ ToolUseStartTime: &startTime,
+ }
+ sendErr := func(err error) {
+ // Record end time
+ endTime := time.Now()
+ content.ToolUseEndTime = &endTime
+
+ content.ToolError = true
+ content.ToolResult = []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: err.Error(),
+ }}
+ c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, nil, err)
+ toolResultC <- content
+ }
+ sendRes := func(toolOut llm.ToolOut) {
+ // Record end time
+ endTime := time.Now()
+ content.ToolUseEndTime = &endTime
+
+ content.ToolResult = toolOut.LLMContent
+ content.Display = toolOut.Display
+ var firstText string
+ if len(toolOut.LLMContent) > 0 {
+ firstText = toolOut.LLMContent[0].Text
+ }
+ c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, &firstText, nil)
+ toolResultC <- content
+ }
+
+ tool, err := c.findTool(part.ToolName)
+ if err != nil {
+ sendErr(err)
+ return
+ }
+ // Create a new context for just this tool_use call, and register its
+ // cancel function so that it can be canceled individually.
+ toolUseCtx, cancel := c.newToolUseContext(ctx, part.ID)
+ defer cancel()
+ // TODO: move this into newToolUseContext?
+ toolUseCtx = context.WithValue(toolUseCtx, toolCallInfoKey, ToolCallInfo{ToolUseID: part.ID})
+ toolOut := tool.Run(toolUseCtx, part.ToolInput)
+ if errors.Is(toolOut.Error, ErrDoNotRespond) {
+ return
+ }
+ if toolUseCtx.Err() != nil {
+ sendErr(context.Cause(toolUseCtx))
+ return
+ }
+
+ if toolOut.Error != nil {
+ sendErr(toolOut.Error)
+ return
+ }
+ sendRes(toolOut)
+ }()
+ }
+ wg.Wait()
+ close(toolResultC)
+ var toolResults []llm.Content
+ for toolResult := range toolResultC {
+ toolResults = append(toolResults, toolResult)
+ }
+ if ctx.Err() != nil {
+ return nil, false, ctx.Err()
+ }
+ return toolResults, endsTurn, nil
+}
+
+func (c *Convo) incrementToolUse(name string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.usage.ToolUses[name]++
+}
+
+// CumulativeUsage represents cumulative usage across a Convo, including all sub-conversations.
+type CumulativeUsage struct {
+ StartTime time.Time `json:"start_time"`
+ Responses uint64 `json:"messages"` // count of responses
+ InputTokens uint64 `json:"input_tokens"`
+ OutputTokens uint64 `json:"output_tokens"`
+ CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
+ CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
+ TotalCostUSD float64 `json:"total_cost_usd"`
+ ToolUses map[string]int `json:"tool_uses"` // tool name -> number of uses
+}
+
+func newUsage() *CumulativeUsage {
+ return &CumulativeUsage{ToolUses: make(map[string]int), StartTime: time.Now()}
+}
+
+func newUsageWithSharedToolUses(parent *CumulativeUsage) *CumulativeUsage {
+ return &CumulativeUsage{ToolUses: parent.ToolUses, StartTime: time.Now()}
+}
+
+func (u *CumulativeUsage) Clone() CumulativeUsage {
+ v := *u
+ v.ToolUses = maps.Clone(u.ToolUses)
+ return v
+}
+
+func (c *Convo) CumulativeUsage() CumulativeUsage {
+ if c == nil {
+ return CumulativeUsage{}
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.usage.Clone()
+}
+
+// LastUsage returns the usage from the most recent API call
+func (c *Convo) LastUsage() llm.Usage {
+ if c == nil {
+ return llm.Usage{}
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.lastUsage
+}
+
+func (u *CumulativeUsage) WallTime() time.Duration {
+ return time.Since(u.StartTime)
+}
+
+func (u *CumulativeUsage) DollarsPerHour() float64 {
+ hours := u.WallTime().Hours()
+ // Prevent division by very small numbers that could cause issues
+ if hours < 1e-6 {
+ return 0
+ }
+ return u.TotalCostUSD / hours
+}
+
+func (u *CumulativeUsage) Add(usage llm.Usage) {
+ u.Responses++
+ u.InputTokens += usage.InputTokens
+ u.OutputTokens += usage.OutputTokens
+ u.CacheReadInputTokens += usage.CacheReadInputTokens
+ u.CacheCreationInputTokens += usage.CacheCreationInputTokens
+ u.TotalCostUSD += usage.CostUSD
+}
+
+// TotalInputTokens returns the grand total cumulative input tokens in u.
+func (u *CumulativeUsage) TotalInputTokens() uint64 {
+ return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
+}
+
+// Attr returns the cumulative usage as a slog.Attr with key "usage".
+func (u CumulativeUsage) Attr() slog.Attr {
+ elapsed := time.Since(u.StartTime)
+ return slog.Group("usage",
+ slog.Duration("wall_time", elapsed),
+ slog.Uint64("responses", u.Responses),
+ slog.Uint64("input_tokens", u.InputTokens),
+ slog.Uint64("output_tokens", u.OutputTokens),
+ slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
+ slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
+ slog.Float64("total_cost_usd", u.TotalCostUSD),
+ slog.Float64("dollars_per_hour", u.TotalCostUSD/elapsed.Hours()),
+ slog.Any("tool_uses", maps.Clone(u.ToolUses)),
+ )
+}
+
+// A Budget represents the maximum amount of resources that may be spent on a conversation.
+// Note that the default (zero) budget is unlimited.
+type Budget struct {
+ MaxDollars float64 // if > 0, max dollars that may be spent
+}
+
+// OverBudget returns an error if the convo (or any of its parents) has exceeded its budget.
+// TODO: document parent vs sub budgets, multiple errors, etc, once we know the desired behavior.
+func (c *Convo) OverBudget() error {
+ for x := c; x != nil; x = x.Parent {
+ if err := x.overBudget(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ResetBudget sets the budget to the passed in budget and
+// adjusts it by what's been used so far.
+func (c *Convo) ResetBudget(budget Budget) {
+ c.Budget = budget
+ if c.Budget.MaxDollars > 0 {
+ c.Budget.MaxDollars += c.CumulativeUsage().TotalCostUSD
+ }
+}
+
+func (c *Convo) overBudget() error {
+ usage := c.CumulativeUsage()
+ // TODO: stop before we exceed the budget instead of after?
+ var err error
+ cont := "Continuing to chat will reset the budget."
+ if c.Budget.MaxDollars > 0 && usage.TotalCostUSD >= c.Budget.MaxDollars {
+ err = errors.Join(err, fmt.Errorf("$%.2f spent, budget is $%.2f. %s", usage.TotalCostUSD, c.Budget.MaxDollars, cont))
+ }
+ return err
+}
+
+// DebugJSON returns the conversation history as JSON for debugging purposes.
+func (c *Convo) DebugJSON() ([]byte, error) {
+ return json.MarshalIndent(c.messages, "", " ")
+}
@@ -0,0 +1,299 @@
+package conversation
+
+import (
+ "cmp"
+ "context"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "testing"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/ant"
+ "sketch.dev/httprr"
+)
+
+func TestBasicConvo(t *testing.T) {
+ ctx := context.Background()
+ rr, err := httprr.Open("testdata/basic_convo.httprr", http.DefaultTransport)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rr.ScrubReq(func(req *http.Request) error {
+ req.Header.Del("x-api-key")
+ return nil
+ })
+
+ apiKey := cmp.Or(os.Getenv("OUTER_SKETCH_MODEL_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
+ srv := &ant.Service{
+ APIKey: apiKey,
+ Model: ant.Claude4Sonnet, // Use specific model to match cached responses
+ HTTPC: rr.Client(),
+ }
+ convo := New(ctx, srv, nil)
+
+ const name = "Cornelius"
+ res, err := convo.SendUserTextMessage("Hi, my name is " + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, part := range res.Content {
+ t.Logf("%s", part.Text)
+ }
+ res, err = convo.SendUserTextMessage("What is my name?")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := ""
+ for _, part := range res.Content {
+ got += part.Text
+ }
+ if !strings.Contains(got, name) {
+ t.Errorf("model does not know the given name %s: %q", name, got)
+ }
+}
+
+// TestCancelToolUse tests the CancelToolUse function of the Convo struct
+func TestCancelToolUse(t *testing.T) {
+ tests := []struct {
+ name string
+ setupToolUse bool
+ toolUseID string
+ cancelErr error
+ expectError bool
+ expectCancel bool
+ }{
+ {
+ name: "Cancel existing tool use",
+ setupToolUse: true,
+ toolUseID: "tool123",
+ cancelErr: nil,
+ expectError: false,
+ expectCancel: true,
+ },
+ {
+ name: "Cancel existing tool use with error",
+ setupToolUse: true,
+ toolUseID: "tool456",
+ cancelErr: context.Canceled,
+ expectError: false,
+ expectCancel: true,
+ },
+ {
+ name: "Cancel non-existent tool use",
+ setupToolUse: false,
+ toolUseID: "tool789",
+ cancelErr: nil,
+ expectError: true,
+ expectCancel: false,
+ },
+ }
+
+ srv := &ant.Service{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ convo := New(context.Background(), srv, nil)
+
+ var cancelCalled bool
+ var cancelledWithErr error
+
+ if tt.setupToolUse {
+ // Setup a mock cancel function to track calls
+ mockCancel := func(err error) {
+ cancelCalled = true
+ cancelledWithErr = err
+ }
+
+ convo.toolUseCancelMu.Lock()
+ convo.toolUseCancel[tt.toolUseID] = mockCancel
+ convo.toolUseCancelMu.Unlock()
+ }
+
+ err := convo.CancelToolUse(tt.toolUseID, tt.cancelErr)
+
+ // Check if we got the expected error state
+ if (err != nil) != tt.expectError {
+ t.Errorf("CancelToolUse() error = %v, expectError %v", err, tt.expectError)
+ }
+
+ // Check if the cancel function was called as expected
+ if cancelCalled != tt.expectCancel {
+ t.Errorf("Cancel function called = %v, expectCancel %v", cancelCalled, tt.expectCancel)
+ }
+
+ // If we expected the cancel to be called, verify it was called with the right error
+ if tt.expectCancel && cancelledWithErr != tt.cancelErr {
+ t.Errorf("Cancel function called with error = %v, expected %v", cancelledWithErr, tt.cancelErr)
+ }
+
+ // Verify the toolUseID was removed from the map if it was initially added
+ if tt.setupToolUse {
+ convo.toolUseCancelMu.Lock()
+ _, exists := convo.toolUseCancel[tt.toolUseID]
+ convo.toolUseCancelMu.Unlock()
+
+ if exists {
+ t.Errorf("toolUseID %s still exists in the map after cancellation", tt.toolUseID)
+ }
+ }
+ })
+ }
+}
+
+// TestInsertMissingToolResults tests the insertMissingToolResults function
+// to ensure it doesn't create duplicate tool results when multiple tool uses are missing results.
+func TestInsertMissingToolResults(t *testing.T) {
+ tests := []struct {
+ name string
+ messages []llm.Message
+ currentMsg llm.Message
+ expectedCount int
+ expectedToolIDs []string
+ }{
+ {
+ name: "Single missing tool result",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "tool1",
+ },
+ },
+ },
+ },
+ currentMsg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{},
+ },
+ expectedCount: 1,
+ expectedToolIDs: []string{"tool1"},
+ },
+ {
+ name: "Multiple missing tool results",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "tool1",
+ },
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "tool2",
+ },
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "tool3",
+ },
+ },
+ },
+ },
+ currentMsg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{},
+ },
+ expectedCount: 3,
+ expectedToolIDs: []string{"tool1", "tool2", "tool3"},
+ },
+ {
+ name: "No missing tool results when results already present",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "tool1",
+ },
+ },
+ },
+ },
+ currentMsg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "tool1",
+ },
+ },
+ },
+ expectedCount: 1, // Only the existing one
+ expectedToolIDs: []string{"tool1"},
+ },
+ {
+ name: "No tool uses in previous message",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: "Just some text",
+ },
+ },
+ },
+ },
+ currentMsg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{},
+ },
+ expectedCount: 0,
+ expectedToolIDs: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv := &ant.Service{}
+ convo := New(context.Background(), srv, nil)
+
+ // Create request with messages
+ req := &llm.Request{
+ Messages: append(tt.messages, tt.currentMsg),
+ }
+
+ // Call insertMissingToolResults
+ msg := tt.currentMsg
+ convo.insertMissingToolResults(req, &msg)
+
+ // Count tool results in the message
+ toolResultCount := 0
+ toolIDs := []string{}
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ toolResultCount++
+ toolIDs = append(toolIDs, content.ToolUseID)
+ }
+ }
+
+ // Verify count
+ if toolResultCount != tt.expectedCount {
+ t.Errorf("Expected %d tool results, got %d", tt.expectedCount, toolResultCount)
+ }
+
+ // Verify no duplicates by checking unique tool IDs
+ seenIDs := make(map[string]int)
+ for _, id := range toolIDs {
+ seenIDs[id]++
+ }
+
+ // Check for duplicates
+ for id, count := range seenIDs {
+ if count > 1 {
+ t.Errorf("Duplicate tool result for ID %s: found %d times", id, count)
+ }
+ }
+
+ // Verify all expected tool IDs are present
+ for _, expectedID := range tt.expectedToolIDs {
+ if !slices.Contains(toolIDs, expectedID) {
+ t.Errorf("Expected tool ID %s not found in results", expectedID)
+ }
+ }
+ })
+ }
+}
@@ -0,0 +1,118 @@
+httprr trace v1
+455 1424
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 259
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-sonnet-4-20250514",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hi, my name is Cornelius",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:38Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:38Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:36Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:38Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 944f30fd0f0a15d4-SJC
+Content-Type: application/json
+Date: Sat, 24 May 2025 19:27:38 GMT
+Request-Id: req_011CPSuX337qwfNzNzGSwG3b
+Server: cloudflare
+Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01L127Hi3H8X613Fh8HojDgk","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":30,"service_tier":"standard"}}775 1394
+POST https://api.anthropic.com/v1/messages HTTP/1.1
+Host: api.anthropic.com
+User-Agent: Go-http-client/1.1
+Content-Length: 579
+Anthropic-Version: 2023-06-01
+Content-Type: application/json
+
+{
+ "model": "claude-sonnet-4-20250514",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hi, my name is Cornelius"
+ }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "What is my name?",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ],
+ "max_tokens": 8192
+}HTTP/2.0 200 OK
+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
+Anthropic-Ratelimit-Input-Tokens-Limit: 200000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:39Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 80000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:40Z
+Anthropic-Ratelimit-Requests-Limit: 4000
+Anthropic-Ratelimit-Requests-Remaining: 3999
+Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:38Z
+Anthropic-Ratelimit-Tokens-Limit: 280000
+Anthropic-Ratelimit-Tokens-Remaining: 280000
+Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:39Z
+Cf-Cache-Status: DYNAMIC
+Cf-Ray: 944f31098c9e15d4-SJC
+Content-Type: application/json
+Date: Sat, 24 May 2025 19:27:40 GMT
+Request-Id: req_011CPSuXBim8ntiKJDjvFUWG
+Server: cloudflare
+Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
+Via: 1.1 google
+X-Robots-Tag: none
+
+{"id":"msg_01TiEuRrzLgJEfBUNhZ9Am3B","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Your name is Cornelius, as you introduced yourself in your first message."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":53,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":19,"service_tier":"standard"}}
@@ -0,0 +1,607 @@
+package gem
+
+import (
+ "cmp"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "math/rand"
+ "net/http"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/gem/gemini"
+)
+
+const (
+ DefaultModel = "gemini-2.5-pro-preview-03-25"
+ GeminiAPIKeyEnv = "GEMINI_API_KEY"
+)
+
+// Service provides Gemini completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ URL string // Gemini API URL, uses the gemini package default if empty
+ APIKey string // must be non-empty
+ Model string // defaults to DefaultModel if empty
+ DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*Service)(nil)
+
+// These maps convert between Sketch's llm package and Gemini API formats
+var fromLLMRole = map[llm.MessageRole]string{
+ llm.MessageRoleAssistant: "model",
+ llm.MessageRoleUser: "user",
+}
+
+// convertToolSchemas converts Sketch's llm.Tool schemas to Gemini's schema format
+func convertToolSchemas(tools []*llm.Tool) ([]gemini.FunctionDeclaration, error) {
+ if len(tools) == 0 {
+ return nil, nil
+ }
+
+ var decls []gemini.FunctionDeclaration
+ for _, tool := range tools {
+ // Parse the schema from raw JSON
+ var schemaJSON map[string]any
+ if err := json.Unmarshal(tool.InputSchema, &schemaJSON); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal tool %s schema: %w", tool.Name, err)
+ }
+ decls = append(decls, gemini.FunctionDeclaration{
+ Name: tool.Name,
+ Description: tool.Description,
+ Parameters: convertJSONSchemaToGeminiSchema(schemaJSON),
+ })
+ }
+
+ return decls, nil
+}
+
+// convertJSONSchemaToGeminiSchema converts a JSON schema to Gemini's schema format
+func convertJSONSchemaToGeminiSchema(schemaJSON map[string]any) gemini.Schema {
+ schema := gemini.Schema{}
+
+ // Set the type based on the JSON schema type
+ if typeVal, ok := schemaJSON["type"].(string); ok {
+ switch typeVal {
+ case "string":
+ schema.Type = gemini.DataTypeSTRING
+ case "number":
+ schema.Type = gemini.DataTypeNUMBER
+ case "integer":
+ schema.Type = gemini.DataTypeINTEGER
+ case "boolean":
+ schema.Type = gemini.DataTypeBOOLEAN
+ case "array":
+ schema.Type = gemini.DataTypeARRAY
+ case "object":
+ schema.Type = gemini.DataTypeOBJECT
+ default:
+ schema.Type = gemini.DataTypeSTRING // Default to string for unknown types
+ }
+ }
+
+ // Set description if available
+ if desc, ok := schemaJSON["description"].(string); ok {
+ schema.Description = desc
+ }
+
+ // Handle enum values
+ if enumValues, ok := schemaJSON["enum"].([]any); ok {
+ schema.Enum = make([]string, len(enumValues))
+ for i, v := range enumValues {
+ if strVal, ok := v.(string); ok {
+ schema.Enum[i] = strVal
+ } else {
+ // Convert non-string values to string
+ valBytes, _ := json.Marshal(v)
+ schema.Enum[i] = string(valBytes)
+ }
+ }
+ }
+
+ // Handle object properties
+ if properties, ok := schemaJSON["properties"].(map[string]any); ok && schema.Type == gemini.DataTypeOBJECT {
+ schema.Properties = make(map[string]gemini.Schema)
+ for propName, propSchema := range properties {
+ if propSchemaMap, ok := propSchema.(map[string]any); ok {
+ schema.Properties[propName] = convertJSONSchemaToGeminiSchema(propSchemaMap)
+ }
+ }
+ }
+
+ // Handle required properties
+ if required, ok := schemaJSON["required"].([]any); ok {
+ schema.Required = make([]string, len(required))
+ for i, r := range required {
+ if strVal, ok := r.(string); ok {
+ schema.Required[i] = strVal
+ }
+ }
+ }
+
+ // Handle array items
+ if items, ok := schemaJSON["items"].(map[string]any); ok && schema.Type == gemini.DataTypeARRAY {
+ itemSchema := convertJSONSchemaToGeminiSchema(items)
+ schema.Items = &itemSchema
+ }
+
+ // Handle minimum/maximum items for arrays
+ if minItems, ok := schemaJSON["minItems"].(float64); ok {
+ schema.MinItems = fmt.Sprintf("%d", int(minItems))
+ }
+ if maxItems, ok := schemaJSON["maxItems"].(float64); ok {
+ schema.MaxItems = fmt.Sprintf("%d", int(maxItems))
+ }
+
+ return schema
+}
+
+// buildGeminiRequest converts Sketch's llm.Request to Gemini's request format
+func (s *Service) buildGeminiRequest(req *llm.Request) (*gemini.Request, error) {
+ gemReq := &gemini.Request{}
+
+ // Add system instruction if provided
+ if len(req.System) > 0 {
+ // Combine all system messages into a single system instruction
+ systemText := ""
+ for i, sys := range req.System {
+ if i > 0 && systemText != "" && sys.Text != "" {
+ systemText += "\n"
+ }
+ systemText += sys.Text
+ }
+
+ if systemText != "" {
+ gemReq.SystemInstruction = &gemini.Content{
+ Parts: []gemini.Part{{Text: systemText}},
+ }
+ }
+ }
+
+ // Convert messages to Gemini content format
+ for _, msg := range req.Messages {
+ // Set the role based on the message role
+ role, ok := fromLLMRole[msg.Role]
+ if !ok {
+ return nil, fmt.Errorf("unsupported message role: %v", msg.Role)
+ }
+
+ content := gemini.Content{
+ Role: role,
+ }
+
+ // Store tool usage information to correlate tool uses with responses
+ toolNameToID := make(map[string]string)
+
+ // First pass: collect tool use IDs for correlation
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolUse && c.ID != "" {
+ toolNameToID[c.ToolName] = c.ID
+ }
+ }
+
+ // Map each content item to Gemini's format
+ for _, c := range msg.Content {
+ switch c.Type {
+ case llm.ContentTypeText, llm.ContentTypeThinking, llm.ContentTypeRedactedThinking:
+ // Simple text content
+ content.Parts = append(content.Parts, gemini.Part{
+ Text: c.Text,
+ })
+ case llm.ContentTypeToolUse:
+ // Tool use becomes a function call
+ var args map[string]any
+ if err := json.Unmarshal(c.ToolInput, &args); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal tool input: %w", err)
+ }
+
+ // Make sure we have a valid ID for this tool use
+ if c.ID == "" {
+ c.ID = fmt.Sprintf("gemini_tool_%s_%d", c.ToolName, time.Now().UnixNano())
+ }
+
+ // Save the ID for this tool name for future correlation
+ toolNameToID[c.ToolName] = c.ID
+
+ slog.DebugContext(context.Background(), "gemini_preparing_tool_use",
+ "tool_name", c.ToolName,
+ "tool_id", c.ID,
+ "input", string(c.ToolInput))
+
+ content.Parts = append(content.Parts, gemini.Part{
+ FunctionCall: &gemini.FunctionCall{
+ Name: c.ToolName,
+ Args: args,
+ },
+ })
+ case llm.ContentTypeToolResult:
+ // Tool result becomes a function response
+ // Create a map for the response
+ response := map[string]any{
+ "error": c.ToolError,
+ }
+
+ // Handle tool results: Gemini only supports string results
+ // Combine all text content into a single string
+ var resultText string
+ if len(c.ToolResult) > 0 {
+ // Collect all text from content objects
+ texts := make([]string, 0, len(c.ToolResult))
+ for _, result := range c.ToolResult {
+ if result.Text != "" {
+ texts = append(texts, result.Text)
+ }
+ }
+ resultText = strings.Join(texts, "\n")
+ }
+ response["result"] = resultText
+
+ // Determine the function name to use - this is critical
+ funcName := ""
+
+ // First try to find the function name from a stored toolUseID if we have one
+ if c.ToolUseID != "" {
+ // Try to derive the tool name from the previous tools we've seen
+ for name, id := range toolNameToID {
+ if id == c.ToolUseID {
+ funcName = name
+ break
+ }
+ }
+ }
+
+ // Fallback options if we couldn't find the tool name
+ if funcName == "" {
+ // Try the tool name directly
+ if c.ToolName != "" {
+ funcName = c.ToolName
+ } else {
+ // Last resort fallback
+ funcName = "default_tool"
+ }
+ }
+
+ slog.DebugContext(context.Background(), "gemini_preparing_tool_result",
+ "tool_use_id", c.ToolUseID,
+ "mapped_func_name", funcName,
+ "result_count", len(c.ToolResult))
+
+ content.Parts = append(content.Parts, gemini.Part{
+ FunctionResponse: &gemini.FunctionResponse{
+ Name: funcName,
+ Response: response,
+ },
+ })
+ }
+ }
+
+ gemReq.Contents = append(gemReq.Contents, content)
+ }
+
+ // Handle tools/functions
+ if len(req.Tools) > 0 {
+ // Convert tool schemas
+ decls, err := convertToolSchemas(req.Tools)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert tool schemas: %w", err)
+ }
+ if len(decls) > 0 {
+ gemReq.Tools = []gemini.Tool{{FunctionDeclarations: decls}}
+ }
+ }
+
+ return gemReq, nil
+}
+
+// convertGeminiResponsesToContent converts a Gemini response to llm.Content
+func convertGeminiResponseToContent(res *gemini.Response) []llm.Content {
+ if res == nil || len(res.Candidates) == 0 || len(res.Candidates[0].Content.Parts) == 0 {
+ return []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: "",
+ }}
+ }
+
+ var contents []llm.Content
+
+ // Process each part in the first candidate's content
+ for i, part := range res.Candidates[0].Content.Parts {
+ // Log the part type for debugging
+ slog.DebugContext(context.Background(), "processing_gemini_part",
+ "index", i,
+ "has_text", part.Text != "",
+ "has_function_call", part.FunctionCall != nil,
+ "has_function_response", part.FunctionResponse != nil)
+
+ if part.Text != "" {
+ // Simple text response
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeText,
+ Text: part.Text,
+ })
+ } else if part.FunctionCall != nil {
+ // Function call (tool use)
+ args, err := json.Marshal(part.FunctionCall.Args)
+ if err != nil {
+ // If we can't marshal, use empty args
+ slog.DebugContext(context.Background(), "gemini_failed_to_markshal_args",
+ "tool_name", part.FunctionCall.Name,
+ "args", string(args),
+ "err", err.Error(),
+ )
+ args = []byte("{}")
+ }
+
+ // Generate a unique ID for this tool use that includes the function name
+ // to make it easier to correlate with responses
+ toolID := fmt.Sprintf("gemini_tool_%s_%d", part.FunctionCall.Name, time.Now().UnixNano())
+
+ contents = append(contents, llm.Content{
+ ID: toolID,
+ Type: llm.ContentTypeToolUse,
+ ToolName: part.FunctionCall.Name,
+ ToolInput: json.RawMessage(args),
+ })
+
+ slog.DebugContext(context.Background(), "gemini_tool_call",
+ "tool_id", toolID,
+ "tool_name", part.FunctionCall.Name,
+ "args", string(args))
+ } else if part.FunctionResponse != nil {
+ // We shouldn't normally get function responses from the model, but just in case
+ respData, _ := json.Marshal(part.FunctionResponse.Response)
+ slog.DebugContext(context.Background(), "unexpected_function_response",
+ "name", part.FunctionResponse.Name,
+ "response", string(respData))
+ }
+ }
+
+ // If no content was added, add an empty text content
+ if len(contents) == 0 {
+ slog.DebugContext(context.Background(), "empty_gemini_response", "adding_empty_text", true)
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeText,
+ Text: "",
+ })
+ }
+
+ return contents
+}
+
+// Gemini doesn't provide usage info directly, so we need to estimate it
+// ensureToolIDs makes sure all tool uses have proper IDs
+func ensureToolIDs(contents []llm.Content) {
+ for i, content := range contents {
+ if content.Type == llm.ContentTypeToolUse && content.ID == "" {
+ // Generate a stable ID using the tool name and timestamp
+ contents[i].ID = fmt.Sprintf("gemini_tool_%s_%d", content.ToolName, time.Now().UnixNano())
+ slog.DebugContext(context.Background(), "assigned_missing_tool_id",
+ "tool_name", content.ToolName,
+ "new_id", contents[i].ID)
+ }
+ }
+}
+
+func calculateUsage(req *gemini.Request, res *gemini.Response) llm.Usage {
+ // Very rough estimation of token counts
+ var inputTokens uint64
+ var outputTokens uint64
+
+ // Count system tokens
+ if req.SystemInstruction != nil {
+ for _, part := range req.SystemInstruction.Parts {
+ if part.Text != "" {
+ // Very rough estimation: 1 token per 4 characters
+ inputTokens += uint64(len(part.Text)) / 4
+ }
+ }
+ }
+
+ // Count input tokens
+ for _, content := range req.Contents {
+ for _, part := range content.Parts {
+ if part.Text != "" {
+ inputTokens += uint64(len(part.Text)) / 4
+ } else if part.FunctionCall != nil {
+ // Estimate function call tokens
+ argBytes, _ := json.Marshal(part.FunctionCall.Args)
+ inputTokens += uint64(len(part.FunctionCall.Name)+len(argBytes)) / 4
+ } else if part.FunctionResponse != nil {
+ // Estimate function response tokens
+ resBytes, _ := json.Marshal(part.FunctionResponse.Response)
+ inputTokens += uint64(len(part.FunctionResponse.Name)+len(resBytes)) / 4
+ }
+ }
+ }
+
+ // Count output tokens
+ if res != nil && len(res.Candidates) > 0 {
+ for _, part := range res.Candidates[0].Content.Parts {
+ if part.Text != "" {
+ outputTokens += uint64(len(part.Text)) / 4
+ } else if part.FunctionCall != nil {
+ // Estimate function call tokens
+ argBytes, _ := json.Marshal(part.FunctionCall.Args)
+ outputTokens += uint64(len(part.FunctionCall.Name)+len(argBytes)) / 4
+ }
+ }
+ }
+
+ return llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ }
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+ model := s.Model
+ if model == "" {
+ model = DefaultModel
+ }
+
+ // Gemini models generally have large context windows
+ switch model {
+ case "gemini-2.5-pro-preview-03-25":
+ return 1000000 // 1M tokens for Gemini 2.5 Pro
+ case "gemini-2.0-flash-exp":
+ return 1000000 // 1M tokens for Gemini 2.0 Flash
+ case "gemini-1.5-pro", "gemini-1.5-pro-latest":
+ return 2000000 // 2M tokens for Gemini 1.5 Pro
+ case "gemini-1.5-flash", "gemini-1.5-flash-latest":
+ return 1000000 // 1M tokens for Gemini 1.5 Flash
+ default:
+ // Default for unknown models
+ return 1000000
+ }
+}
+
+// Do sends a request to Gemini.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+ // Log the incoming request for debugging
+ slog.DebugContext(ctx, "gemini_request",
+ "message_count", len(ir.Messages),
+ "tool_count", len(ir.Tools),
+ "system_count", len(ir.System))
+
+ // Log tool-related information if any tools are present
+ if len(ir.Tools) > 0 {
+ var toolNames []string
+ for _, tool := range ir.Tools {
+ toolNames = append(toolNames, tool.Name)
+ }
+ slog.DebugContext(ctx, "gemini_tools", "tools", toolNames)
+ }
+
+ // Log details about the messages being sent
+ for i, msg := range ir.Messages {
+ contentTypes := make([]string, len(msg.Content))
+ for j, c := range msg.Content {
+ contentTypes[j] = c.Type.String()
+
+ // Log tool-related content with more details
+ if c.Type == llm.ContentTypeToolUse {
+ slog.DebugContext(ctx, "gemini_tool_use",
+ "message_idx", i,
+ "content_idx", j,
+ "tool_name", c.ToolName,
+ "tool_input", string(c.ToolInput))
+ } else if c.Type == llm.ContentTypeToolResult {
+ slog.DebugContext(ctx, "gemini_tool_result",
+ "message_idx", i,
+ "content_idx", j,
+ "tool_use_id", c.ToolUseID,
+ "tool_error", c.ToolError,
+ "result_count", len(c.ToolResult))
+ }
+ }
+ slog.DebugContext(ctx, "gemini_message",
+ "idx", i,
+ "role", msg.Role.String(),
+ "content_types", contentTypes)
+ }
+ // Build the Gemini request
+ gemReq, err := s.buildGeminiRequest(ir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to build Gemini request: %w", err)
+ }
+
+ // Log the structured Gemini request for debugging
+ if reqJSON, err := json.MarshalIndent(gemReq, "", " "); err == nil {
+ slog.DebugContext(ctx, "gemini_request_json", "request", string(reqJSON))
+ if s.DumpLLM {
+ // Construct the same URL that the Gemini client will use
+ endpoint := cmp.Or(s.URL, "https://generativelanguage.googleapis.com/v1beta")
+ url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", endpoint, cmp.Or(s.Model, DefaultModel), s.APIKey)
+ if err := llm.DumpToFile("request", url, reqJSON); err != nil {
+ slog.WarnContext(ctx, "failed to dump gemini request to file", "error", err)
+ }
+ }
+ }
+
+ // Create a Gemini model instance
+ model := gemini.Model{
+ Model: "models/" + cmp.Or(s.Model, DefaultModel),
+ Endpoint: s.URL,
+ APIKey: s.APIKey,
+ HTTPC: cmp.Or(s.HTTPC, http.DefaultClient),
+ }
+
+ // Send the request to Gemini with retry logic
+ startTime := time.Now()
+ endTime := startTime // Initialize endTime
+ var gemRes *gemini.Response
+
+ // Retry mechanism for handling server errors and rate limiting
+ backoff := []time.Duration{1 * time.Second, 3 * time.Second, 5 * time.Second, 10 * time.Second}
+ for attempts := 0; attempts <= len(backoff); attempts++ {
+ gemApiErr := error(nil)
+ gemRes, gemApiErr = model.GenerateContent(ctx, gemReq)
+ endTime = time.Now()
+
+ if gemApiErr == nil {
+ // Successful response
+ // Log the structured Gemini response
+ if resJSON, err := json.MarshalIndent(gemRes, "", " "); err == nil {
+ slog.DebugContext(ctx, "gemini_response_json", "response", string(resJSON))
+ if s.DumpLLM {
+ if err := llm.DumpToFile("response", "", resJSON); err != nil {
+ slog.WarnContext(ctx, "failed to dump gemini response to file", "error", err)
+ }
+ }
+ }
+ break
+ }
+
+ if attempts == len(backoff) {
+ // We've exhausted all retry attempts
+ return nil, fmt.Errorf("gemini: API error after %d attempts: %w", attempts, gemApiErr)
+ }
+
+ // Check if the error is retryable (e.g., server error or rate limiting)
+ if strings.Contains(gemApiErr.Error(), "429") || strings.Contains(gemApiErr.Error(), "5") {
+ // Rate limited or server error - wait and retry
+ random := time.Duration(rand.Int63n(int64(time.Second)))
+ sleep := backoff[attempts] + random
+ slog.WarnContext(ctx, "gemini_request_retry", "error", gemApiErr.Error(), "attempt", attempts+1, "sleep", sleep)
+ time.Sleep(sleep)
+ continue
+ }
+
+ // Non-retryable error
+ return nil, fmt.Errorf("gemini: API error: %w", gemApiErr)
+ }
+
+ content := convertGeminiResponseToContent(gemRes)
+
+ ensureToolIDs(content)
+
+ usage := calculateUsage(gemReq, gemRes)
+ usage.CostUSD = llm.CostUSDFromResponse(gemRes.Header())
+
+ stopReason := llm.StopReasonEndTurn
+ for _, part := range content {
+ if part.Type == llm.ContentTypeToolUse {
+ stopReason = llm.StopReasonToolUse
+ slog.DebugContext(ctx, "gemini_tool_use_detected",
+ "setting_stop_reason", "llm.StopReasonToolUse",
+ "tool_name", part.ToolName)
+ break
+ }
+ }
+
+ return &llm.Response{
+ Role: llm.MessageRoleAssistant,
+ Model: s.Model,
+ Content: content,
+ StopReason: stopReason,
+ Usage: usage,
+ StartTime: &startTime,
+ EndTime: &endTime,
+ }, nil
+}
@@ -0,0 +1,366 @@
+package gem
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/gem/gemini"
+)
+
+func TestBuildGeminiRequest(t *testing.T) {
+ // Create a service
+ service := &Service{
+ Model: DefaultModel,
+ APIKey: "test-api-key",
+ }
+
+ // Create a simple request
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: "Hello, world!",
+ },
+ },
+ },
+ },
+ System: []llm.SystemContent{
+ {
+ Text: "You are a helpful assistant.",
+ },
+ },
+ }
+
+ // Build the Gemini request
+ gemReq, err := service.buildGeminiRequest(req)
+ if err != nil {
+ t.Fatalf("Failed to build Gemini request: %v", err)
+ }
+
+ // Verify the system instruction
+ if gemReq.SystemInstruction == nil {
+ t.Fatalf("Expected system instruction, got nil")
+ }
+ if len(gemReq.SystemInstruction.Parts) != 1 {
+ t.Fatalf("Expected 1 system part, got %d", len(gemReq.SystemInstruction.Parts))
+ }
+ if gemReq.SystemInstruction.Parts[0].Text != "You are a helpful assistant." {
+ t.Fatalf("Expected system text 'You are a helpful assistant.', got '%s'", gemReq.SystemInstruction.Parts[0].Text)
+ }
+
+ // Verify the contents
+ if len(gemReq.Contents) != 1 {
+ t.Fatalf("Expected 1 content, got %d", len(gemReq.Contents))
+ }
+ if len(gemReq.Contents[0].Parts) != 1 {
+ t.Fatalf("Expected 1 part, got %d", len(gemReq.Contents[0].Parts))
+ }
+ if gemReq.Contents[0].Parts[0].Text != "Hello, world!" {
+ t.Fatalf("Expected text 'Hello, world!', got '%s'", gemReq.Contents[0].Parts[0].Text)
+ }
+ // Verify the role is set correctly
+ if gemReq.Contents[0].Role != "user" {
+ t.Fatalf("Expected role 'user', got '%s'", gemReq.Contents[0].Role)
+ }
+}
+
+func TestConvertToolSchemas(t *testing.T) {
+ // Create a simple tool with a JSON schema
+ schema := `{
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the person"
+ },
+ "age": {
+ "type": "integer",
+ "description": "The age of the person"
+ }
+ },
+ "required": ["name"]
+ }`
+
+ tools := []*llm.Tool{
+ {
+ Name: "get_person",
+ Description: "Get information about a person",
+ InputSchema: json.RawMessage(schema),
+ },
+ }
+
+ // Convert the tools
+ decls, err := convertToolSchemas(tools)
+ if err != nil {
+ t.Fatalf("Failed to convert tool schemas: %v", err)
+ }
+
+ // Verify the result
+ if len(decls) != 1 {
+ t.Fatalf("Expected 1 declaration, got %d", len(decls))
+ }
+ if decls[0].Name != "get_person" {
+ t.Fatalf("Expected name 'get_person', got '%s'", decls[0].Name)
+ }
+ if decls[0].Description != "Get information about a person" {
+ t.Fatalf("Expected description 'Get information about a person', got '%s'", decls[0].Description)
+ }
+
+ // Verify the schema properties
+ if decls[0].Parameters.Type != 6 { // DataTypeOBJECT
+ t.Fatalf("Expected type OBJECT (6), got %d", decls[0].Parameters.Type)
+ }
+ if len(decls[0].Parameters.Properties) != 2 {
+ t.Fatalf("Expected 2 properties, got %d", len(decls[0].Parameters.Properties))
+ }
+ if decls[0].Parameters.Properties["name"].Type != 1 { // DataTypeSTRING
+ t.Fatalf("Expected name type STRING (1), got %d", decls[0].Parameters.Properties["name"].Type)
+ }
+ if decls[0].Parameters.Properties["age"].Type != 3 { // DataTypeINTEGER
+ t.Fatalf("Expected age type INTEGER (3), got %d", decls[0].Parameters.Properties["age"].Type)
+ }
+ if len(decls[0].Parameters.Required) != 1 || decls[0].Parameters.Required[0] != "name" {
+ t.Fatalf("Expected required field 'name', got %v", decls[0].Parameters.Required)
+ }
+}
+
+func TestService_Do_MockResponse(t *testing.T) {
+ // This is a mock test that doesn't make actual API calls
+ // Create a mock HTTP client that returns a predefined response
+
+ // Create a Service with a mock client
+ service := &Service{
+ Model: DefaultModel,
+ APIKey: "test-api-key",
+ // We would use a mock HTTP client here in a real test
+ }
+
+ // Create a sample request
+ ir := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: "Hello",
+ },
+ },
+ },
+ },
+ }
+
+ // In a real test, we would execute service.Do with a mock client
+ // and verify the response structure
+
+ // For now, we'll just test that buildGeminiRequest works correctly
+ _, err := service.buildGeminiRequest(ir)
+ if err != nil {
+ t.Fatalf("Failed to build request: %v", err)
+ }
+}
+
+func TestConvertResponseWithToolCall(t *testing.T) {
+ // Create a mock Gemini response with a function call
+ gemRes := &gemini.Response{
+ Candidates: []gemini.Candidate{
+ {
+ Content: gemini.Content{
+ Parts: []gemini.Part{
+ {
+ FunctionCall: &gemini.FunctionCall{
+ Name: "bash",
+ Args: map[string]any{
+ "command": "cat README.md",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ // Convert the response
+ content := convertGeminiResponseToContent(gemRes)
+
+ // Verify that content has a tool use
+ if len(content) != 1 {
+ t.Fatalf("Expected 1 content item, got %d", len(content))
+ }
+
+ if content[0].Type != llm.ContentTypeToolUse {
+ t.Fatalf("Expected content type ToolUse, got %s", content[0].Type)
+ }
+
+ if content[0].ToolName != "bash" {
+ t.Fatalf("Expected tool name 'bash', got '%s'", content[0].ToolName)
+ }
+
+ // Verify the tool input
+ var args map[string]any
+ if err := json.Unmarshal(content[0].ToolInput, &args); err != nil {
+ t.Fatalf("Failed to unmarshal tool input: %v", err)
+ }
+
+ cmd, ok := args["command"]
+ if !ok {
+ t.Fatalf("Expected 'command' argument, not found")
+ }
+
+ if cmd != "cat README.md" {
+ t.Fatalf("Expected command 'cat README.md', got '%s'", cmd)
+ }
+}
+
+func TestGeminiHeaderCapture(t *testing.T) {
+ // Create a mock HTTP client that returns a response with headers
+ mockClient := &http.Client{
+ Transport: &mockRoundTripper{
+ response: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"application/json"},
+ "Skaband-Cost-Microcents": []string{"123456"},
+ },
+ Body: io.NopCloser(bytes.NewBufferString(`{
+ "candidates": [{
+ "content": {
+ "parts": [{
+ "text": "Hello!"
+ }]
+ }
+ }]
+ }`)),
+ },
+ },
+ }
+
+ // Create a Gemini model with the mock client
+ model := gemini.Model{
+ Model: "models/gemini-test",
+ APIKey: "test-key",
+ HTTPC: mockClient,
+ Endpoint: "https://test.googleapis.com",
+ }
+
+ // Make a request
+ req := &gemini.Request{
+ Contents: []gemini.Content{
+ {
+ Parts: []gemini.Part{{Text: "Hello"}},
+ Role: "user",
+ },
+ },
+ }
+
+ ctx := context.Background()
+ res, err := model.GenerateContent(ctx, req)
+ if err != nil {
+ t.Fatalf("Failed to generate content: %v", err)
+ }
+
+ // Verify that headers were captured
+ headers := res.Header()
+ if headers == nil {
+ t.Fatalf("Expected headers to be captured, got nil")
+ }
+
+ // Check for the cost header
+ costHeader := headers.Get("Skaband-Cost-Microcents")
+ if costHeader != "123456" {
+ t.Fatalf("Expected cost header '123456', got '%s'", costHeader)
+ }
+
+ // Verify that llm.CostUSDFromResponse works with these headers
+ costUSD := llm.CostUSDFromResponse(headers)
+ expectedCost := 0.00123456 // 123456 microcents / 100,000,000
+ if costUSD != expectedCost {
+ t.Fatalf("Expected cost USD %.8f, got %.8f", expectedCost, costUSD)
+ }
+}
+
+// mockRoundTripper is a mock HTTP transport for testing
+type mockRoundTripper struct {
+ response *http.Response
+}
+
+func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ return m.response, nil
+}
+
+func TestHeaderCostIntegration(t *testing.T) {
+ // Create a mock HTTP client that returns a response with cost headers
+ mockClient := &http.Client{
+ Transport: &mockRoundTripper{
+ response: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"application/json"},
+ "Skaband-Cost-Microcents": []string{"50000"}, // 0.5 USD
+ },
+ Body: io.NopCloser(bytes.NewBufferString(`{
+ "candidates": [{
+ "content": {
+ "parts": [{
+ "text": "Test response"
+ }]
+ }
+ }]
+ }`)),
+ },
+ },
+ }
+
+ // Create a Gem service with the mock client
+ service := &Service{
+ Model: "gemini-test",
+ APIKey: "test-key",
+ HTTPC: mockClient,
+ URL: "https://test.googleapis.com",
+ }
+
+ // Create a request
+ ir := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: "Hello",
+ },
+ },
+ },
+ },
+ }
+
+ // Make the request
+ ctx := context.Background()
+ res, err := service.Do(ctx, ir)
+ if err != nil {
+ t.Fatalf("Failed to make request: %v", err)
+ }
+
+ // Verify that the cost was captured from headers
+ expectedCost := 0.0005 // 50000 microcents / 100,000,000
+ if res.Usage.CostUSD != expectedCost {
+ t.Fatalf("Expected cost USD %.8f, got %.8f", expectedCost, res.Usage.CostUSD)
+ }
+
+ // Verify token counts are still estimated
+ if res.Usage.InputTokens == 0 {
+ t.Fatalf("Expected input tokens to be estimated, got 0")
+ }
+ if res.Usage.OutputTokens == 0 {
+ t.Fatalf("Expected output tokens to be estimated, got 0")
+ }
+}
@@ -0,0 +1,187 @@
+package gemini
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// https://ai.google.dev/api/generate-content#request-body
+type Request struct {
+ Contents []Content `json:"contents"`
+ Tools []Tool `json:"tools,omitempty"`
+ SystemInstruction *Content `json:"systemInstruction,omitempty"`
+ GenerationConfig *GenerationConfig `json:"generationConfig,omitempty"`
+ CachedContent string `json:"cachedContent,omitempty"` // format: "cachedContents/{name}"
+ // ToolConfig has been left out because it does not appear to be useful.
+}
+
+// https://ai.google.dev/api/generate-content#response-body
+type Response struct {
+ Candidates []Candidate `json:"candidates"`
+ headers http.Header // captured HTTP response headers
+}
+
+// Header returns the HTTP response headers.
+func (r *Response) Header() http.Header {
+ return r.headers
+}
+
+type Candidate struct {
+ Content Content `json:"content"`
+}
+
+type Content struct {
+ Parts []Part `json:"parts"`
+ Role string `json:"role,omitempty"`
+}
+
+// Part is a part of the content.
+// This is a union data structure, only one-of the fields can be set.
+type Part struct {
+ Text string `json:"text,omitempty"`
+ FunctionCall *FunctionCall `json:"functionCall,omitempty"`
+ FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
+ ExecutableCode *ExecutableCode `json:"executableCode,omitempty"`
+ CodeExecutionResult *CodeExecutionResult `json:"codeExecutionResult,omitempty"`
+ // TODO inlineData
+ // TODO fileData
+}
+
+type FunctionCall struct {
+ Name string `json:"name"`
+ Args map[string]any `json:"args"`
+}
+
+type FunctionResponse struct {
+ Name string `json:"name"`
+ Response map[string]any `json:"response"`
+}
+
+type ExecutableCode struct {
+ Language Language `json:"language"`
+ Code string `json:"code"`
+}
+
+type Language int
+
+const (
+ LanguageUnspecified Language = 0
+ LanguagePython Language = 1 // python >= 3.10 with numpy and simpy
+)
+
+type CodeExecutionResult struct {
+ Outcome Outcome `json:"outcome"`
+ Output string `json:"output"`
+}
+
+type Outcome int
+
+const (
+ OutcomeUnspecified Outcome = 0
+ OutcomeOK Outcome = 1
+ OutcomeFailed Outcome = 2
+ OutcomeDeadlineExceeded Outcome = 3
+)
+
+// https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
+type GenerationConfig struct {
+ ResponseMimeType string `json:"responseMimeType,omitempty"` // text/plain, application/json, or text/x.enum
+ ResponseSchema *Schema `json:"responseSchema,omitempty"` // for JSON
+}
+
+// https://ai.google.dev/api/caching#Tool
+type Tool struct {
+ FunctionDeclarations []FunctionDeclaration `json:"functionDeclarations"`
+ CodeExecution *struct{} `json:"codeExecution,omitempty"` // if present, enables the model to execute code
+ // TODO googleSearchRetrieval https://ai.google.dev/api/caching#GoogleSearchRetrieval
+}
+
+// https://ai.google.dev/api/caching#FunctionDeclaration
+type FunctionDeclaration struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters Schema `json:"parameters"`
+}
+
+// https://ai.google.dev/api/caching#Schema
+type Schema struct {
+ Type DataType `json:"type"`
+ Format string `json:"string,omitempty"` // for NUMBER type: float, double for INTEGER type: int32, int64 for STRING type: enum
+ Description string `json:"description,omitempty"`
+ Nullable *bool `json:"nullable,omitempty"`
+ Enum []string `json:"enum,omitempty"`
+ MaxItems string `json:"maxItems,omitempty"` // for ARRAY
+ MinItems string `json:"minItems,omitempty"` // for ARRAY
+ Properties map[string]Schema `json:"properties,omitempty"` // for OBJECT
+ Required []string `json:"required,omitempty"` // for OBJECT
+ Items *Schema `json:"items,omitempty"` // for ARRAY
+}
+
+type DataType int
+
+const (
+ DataTypeUNSPECIFIED = DataType(0) // Not specified, should not be used.
+ DataTypeSTRING = DataType(1)
+ DataTypeNUMBER = DataType(2)
+ DataTypeINTEGER = DataType(3)
+ DataTypeBOOLEAN = DataType(4)
+ DataTypeARRAY = DataType(5)
+ DataTypeOBJECT = DataType(6)
+)
+
+const defaultEndpoint = "https://generativelanguage.googleapis.com/v1beta"
+
+type Model struct {
+ Model string // e.g. "models/gemini-1.5-flash"
+ APIKey string
+ HTTPC *http.Client // if nil, http.DefaultClient is used
+ Endpoint string // if empty, DefaultEndpoint is used
+}
+
+func (m Model) GenerateContent(ctx context.Context, req *Request) (*Response, error) {
+ reqBytes, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling request: %w", err)
+ }
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/%s:generateContent?key=%s", m.endpoint(), m.Model, m.APIKey), bytes.NewReader(reqBytes))
+ if err != nil {
+ return nil, fmt.Errorf("creating HTTP request: %w", err)
+ }
+ httpReq.Header.Add("Content-Type", "application/json")
+ httpResp, err := m.httpc().Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateContent: do: %w", err)
+ }
+ defer httpResp.Body.Close()
+ body, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("GenerateContent: reading response body: %w", err)
+ }
+ if httpResp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GenerateContent: HTTP status: %d, %s", httpResp.StatusCode, string(body))
+ }
+ var res Response
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("GenerateContent: unmarshaling response: %w, %s", err, string(body))
+ }
+ res.headers = httpResp.Header
+ return &res, nil
+}
+
+func (m Model) endpoint() string {
+ if m.Endpoint != "" {
+ return m.Endpoint
+ }
+ return defaultEndpoint
+}
+
+func (m Model) httpc() *http.Client {
+ if m.HTTPC != nil {
+ return m.HTTPC
+ }
+ return http.DefaultClient
+}
@@ -0,0 +1,33 @@
+package gemini
+
+import (
+ "context"
+ "os"
+ "testing"
+)
+
+func TestGenerateContent(t *testing.T) {
+ // TODO replace with local replay endpoint
+ m := Model{
+ Model: "models/gemini-1.5-flash",
+ APIKey: os.Getenv("GEMINI_API_KEY"),
+ }
+ if testing.Short() {
+ t.Skip("skipping test in short mode")
+ }
+ if m.APIKey == "" {
+ t.Skip("skipping test without API key")
+ }
+
+ res, err := m.GenerateContent(context.Background(), &Request{
+ Contents: []Content{{
+ Parts: []Part{{
+ Text: "What is the capital of France?",
+ }},
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("res: %+v", res)
+}
@@ -0,0 +1,62 @@
+package llm
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestImageContent(t *testing.T) {
+ // Create a Content structure with an image
+ imageContent := Content{
+ Type: ContentTypeText, // In the future, we might add a specific ContentTypeImage
+ MediaType: "image/jpeg",
+ Data: "/9j/4AAQSkZJRg...", // Shortened base64 encoded image
+ }
+
+ // Verify the structure is correct
+ if imageContent.MediaType != "image/jpeg" {
+ t.Errorf("Expected MediaType to be 'image/jpeg', got '%s'", imageContent.MediaType)
+ }
+
+ if imageContent.Data != "/9j/4AAQSkZJRg..." {
+ t.Errorf("Expected Data to contain base64 image data")
+ }
+
+ // Create a tool result that contains text and image content
+ toolResult := Content{
+ Type: ContentTypeToolResult,
+ ToolUseID: "toolu_01A09q90qw90lq917835lq9",
+ ToolResult: []Content{
+ {
+ Type: ContentTypeText,
+ Text: "15 degrees",
+ },
+ imageContent,
+ },
+ }
+
+ // Check that the tool result contains two content items
+ if len(toolResult.ToolResult) != 2 {
+ t.Errorf("Expected tool result to contain 2 content items, got %d", len(toolResult.ToolResult))
+ }
+
+ // Verify JSON marshaling works as expected
+ bytes, err := json.Marshal(toolResult)
+ if err != nil {
+ t.Errorf("Failed to marshal content to JSON: %v", err)
+ }
+
+ // Unmarshal and verify structure is preserved
+ var unmarshaled Content
+ if err := json.Unmarshal(bytes, &unmarshaled); err != nil {
+ t.Errorf("Failed to unmarshal JSON: %v", err)
+ }
+
+ if len(unmarshaled.ToolResult) != 2 {
+ t.Errorf("Expected unmarshaled tool result to contain 2 content items, got %d", len(unmarshaled.ToolResult))
+ }
+
+ if unmarshaled.ToolResult[1].MediaType != "image/jpeg" {
+ t.Errorf("Expected unmarshaled image MediaType to be 'image/jpeg', got '%s'", unmarshaled.ToolResult[1].MediaType)
+ }
+}
@@ -0,0 +1,367 @@
+// Package llm provides a unified interface for interacting with LLMs.
+package llm
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Service interface {
+ // Do sends a request to an LLM.
+ Do(context.Context, *Request) (*Response, error)
+ // TokenContextWindow returns the maximum token context window size for this service
+ TokenContextWindow() int
+}
+
+type SimplifiedPatcher interface {
+ // UseSimplifiedPatch reports whether the service should use the simplified patch input schema.
+ UseSimplifiedPatch() bool
+}
+
+func UseSimplifiedPatch(svc Service) bool {
+ if sp, ok := svc.(SimplifiedPatcher); ok {
+ return sp.UseSimplifiedPatch()
+ }
+ return false
+}
+
+// MustSchema validates that schema is a valid JSON schema and returns it as a json.RawMessage.
+// It panics if the schema is invalid.
+// The schema must have at least type="object" and a properties key.
+func MustSchema(schema string) json.RawMessage {
+ schema = strings.TrimSpace(schema)
+ bytes := []byte(schema)
+ var obj map[string]any
+ if err := json.Unmarshal(bytes, &obj); err != nil {
+ panic("failed to parse JSON schema: " + schema + ": " + err.Error())
+ }
+ if typ, ok := obj["type"]; !ok || typ != "object" {
+ panic("JSON schema must have type='object': " + schema)
+ }
+ if _, ok := obj["properties"]; !ok {
+ panic("JSON schema must have 'properties' key: " + schema)
+ }
+ return json.RawMessage(bytes)
+}
+
+func EmptySchema() json.RawMessage {
+ return MustSchema(`{"type": "object", "properties": {}}`)
+}
+
+type Request struct {
+ Messages []Message
+ ToolChoice *ToolChoice
+ Tools []*Tool
+ System []SystemContent
+}
+
+// Message represents a message in the conversation.
+type Message struct {
+ Role MessageRole `json:"Role"`
+ Content []Content `json:"Content"`
+ ToolUse *ToolUse `json:"ToolUse,omitempty"` // use to control whether/which tool to use
+ EndOfTurn bool `json:"EndOfTurn"` // true if this message completes the agent's turn (no tool calls to make)
+}
+
+// ToolUse represents a tool use in the message content.
+type ToolUse struct {
+ ID string
+ Name string
+}
+
+type ToolChoice struct {
+ Type ToolChoiceType
+ Name string
+}
+
+type SystemContent struct {
+ Text string
+ Type string
+ Cache bool
+}
+
+// Tool represents a tool available to an LLM.
+type Tool struct {
+ Name string
+ // Type is used by the text editor tool; see
+ // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
+ Type string
+ Description string
+ InputSchema json.RawMessage
+ // EndsTurn indicates that this tool should cause the model to end its turn when used
+ EndsTurn bool
+ // Cache indicates whether to use prompt caching for this tool
+ Cache bool
+
+ // The Run function is automatically called when the tool is used.
+ // Run functions may be called concurrently with each other and themselves.
+ // The input to Run function is the input to the tool, as provided by Claude, in compliance with the input schema.
+ // The outputs from Run will be sent back to Claude.
+ // If you do not want to respond to the tool call request from Claude, return ErrDoNotRespond.
+ // ctx contains extra (rarely used) tool call information; retrieve it with ToolCallInfoFromContext.
+ Run func(ctx context.Context, input json.RawMessage) ToolOut `json:"-"`
+}
+
+// ToolOut represents the output of a tool run.
+type ToolOut struct {
+ // LLMContent is the output of the tool to be sent back to the LLM.
+ // May be nil on error.
+ LLMContent []Content
+ // Display is content to be displayed to the user.
+ // The type of content is set by the tool and coordinated with the UIs.
+ // It should be JSON-serializable.
+ Display any
+ // Error is the error (if any) that occurred during the tool run.
+ // The text contents of the error will be sent back to the LLM.
+ // If non-nil, LLMContent will be ignored.
+ Error error
+}
+
+type Content struct {
+ ID string
+ Type ContentType
+ Text string
+
+ // Media type for image content
+ MediaType string
+
+ // for thinking
+ Thinking string
+ Data string
+ Signature string
+
+ // for tool_use
+ ToolName string
+ ToolInput json.RawMessage
+
+ // for tool_result
+ ToolUseID string
+ ToolError bool
+ ToolResult []Content
+
+ // timing information for tool_result; added externally; not sent to the LLM
+ ToolUseStartTime *time.Time
+ ToolUseEndTime *time.Time
+
+ // Display is content to be displayed to the user, copied from ToolOut
+ Display any
+
+ Cache bool
+}
+
+func StringContent(s string) Content {
+ return Content{Type: ContentTypeText, Text: s}
+}
+
+// ContentsAttr returns contents as a slog.Attr.
+// It is meant for logging.
+func ContentsAttr(contents []Content) slog.Attr {
+ var contentAttrs []any // slog.Attr
+ for _, content := range contents {
+ var attrs []any // slog.Attr
+ switch content.Type {
+ case ContentTypeText:
+ attrs = append(attrs, slog.String("text", content.Text))
+ case ContentTypeToolUse:
+ attrs = append(attrs, slog.String("tool_name", content.ToolName))
+ attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
+ case ContentTypeToolResult:
+ attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
+ attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
+ case ContentTypeThinking:
+ attrs = append(attrs, slog.String("thinking", content.Text))
+ default:
+ attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
+ attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
+ }
+ contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
+ }
+ return slog.Group("contents", contentAttrs...)
+}
+
+type (
+ MessageRole int
+ ContentType int
+ ToolChoiceType int
+ StopReason int
+)
+
+//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
+
+const (
+ MessageRoleUser MessageRole = iota
+ MessageRoleAssistant
+
+ ContentTypeText ContentType = iota
+ ContentTypeThinking
+ ContentTypeRedactedThinking
+ ContentTypeToolUse
+ ContentTypeToolResult
+
+ ToolChoiceTypeAuto ToolChoiceType = iota // default
+ ToolChoiceTypeAny // any tool, but must use one
+ ToolChoiceTypeNone // no tools allowed
+ ToolChoiceTypeTool // must use the tool specified in the Name field
+
+ StopReasonStopSequence StopReason = iota
+ StopReasonMaxTokens
+ StopReasonEndTurn
+ StopReasonToolUse
+ StopReasonRefusal
+)
+
+type Response struct {
+ ID string
+ Type string
+ Role MessageRole
+ Model string
+ Content []Content
+ StopReason StopReason
+ StopSequence *string
+ Usage Usage
+ StartTime *time.Time
+ EndTime *time.Time
+}
+
+func (m *Response) ToMessage() Message {
+ return Message{
+ Role: m.Role,
+ Content: m.Content,
+ EndOfTurn: m.StopReason != StopReasonToolUse, // End of turn unless there are tools to call
+ }
+}
+
+func CostUSDFromResponse(headers http.Header) float64 {
+ h := headers.Get("Skaband-Cost-Microcents")
+ if h == "" {
+ return 0
+ }
+ uc, err := strconv.ParseUint(h, 10, 64)
+ if err != nil {
+ slog.Warn("failed to parse cost header", "header", h)
+ return 0
+ }
+ return float64(uc) / 100_000_000
+}
+
+// Usage represents the billing and rate-limit usage.
+// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
+// However, the front-end uses this struct, and it relies on its JSON serialization.
+// Do NOT use this struct directly when implementing an llm.Service.
+type Usage struct {
+ InputTokens uint64 `json:"input_tokens"`
+ CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
+ OutputTokens uint64 `json:"output_tokens"`
+ CostUSD float64 `json:"cost_usd"`
+ Model string `json:"model,omitempty"`
+ StartTime *time.Time `json:"start_time,omitempty"`
+ EndTime *time.Time `json:"end_time,omitempty"`
+}
+
+func (u *Usage) Add(other Usage) {
+ u.InputTokens += other.InputTokens
+ u.CacheCreationInputTokens += other.CacheCreationInputTokens
+ u.CacheReadInputTokens += other.CacheReadInputTokens
+ u.OutputTokens += other.OutputTokens
+ u.CostUSD += other.CostUSD
+}
+
+func (u *Usage) String() string {
+ return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
+}
+
+// TotalInputTokens returns the total number of input tokens including cached tokens.
+// This represents the full context that was sent to the model:
+// - InputTokens: tokens processed (not from cache)
+// - CacheCreationInputTokens: tokens written to cache (also part of input)
+// - CacheReadInputTokens: tokens read from cache (also part of input)
+func (u *Usage) TotalInputTokens() uint64 {
+ return u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens
+}
+
+// ContextWindowUsed returns the total context window usage after this response.
+// This is the size of the conversation that would be sent to the model for the next turn:
+// total input tokens + output tokens (which become part of the conversation).
+func (u *Usage) ContextWindowUsed() uint64 {
+ return u.TotalInputTokens() + u.OutputTokens
+}
+
+func (u *Usage) IsZero() bool {
+ return *u == Usage{}
+}
+
+func (u *Usage) Attr() slog.Attr {
+ return slog.Group("usage",
+ slog.Uint64("input_tokens", u.InputTokens),
+ slog.Uint64("output_tokens", u.OutputTokens),
+ slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
+ slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
+ slog.Float64("cost_usd", u.CostUSD),
+ )
+}
+
+// UserStringMessage creates a user message with a single text content item.
+func UserStringMessage(text string) Message {
+ return Message{
+ Role: MessageRoleUser,
+ Content: []Content{StringContent(text)},
+ }
+}
+
+// TextContent creates a simple text content for tool results.
+// This is a helper function to create the most common type of tool result content.
+func TextContent(text string) []Content {
+ return []Content{{
+ Type: ContentTypeText,
+ Text: text,
+ }}
+}
+
+func ErrorToolOut(err error) ToolOut {
+ if err == nil {
+ panic("ErrorToolOut called with nil error")
+ }
+ return ToolOut{
+ Error: err,
+ }
+}
+
+func ErrorfToolOut(format string, args ...any) ToolOut {
+ return ErrorToolOut(fmt.Errorf(format, args...))
+}
+
+// DumpToFile writes LLM communication content to a timestamped file in ~/.cache/sketch/.
+// For requests, it includes the URL followed by the content. For responses, it only includes the content.
+// The typ parameter is used as a prefix in the filename ("request", "response").
+func DumpToFile(typ, url string, content []byte) error {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ cacheDir := filepath.Join(homeDir, ".cache", "sketch")
+ err = os.MkdirAll(cacheDir, 0o700)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ filename := fmt.Sprintf("%s_%d.txt", typ, now.UnixMilli())
+ filePath := filepath.Join(cacheDir, filename)
+
+ // For requests, start with the URL; for responses, just write the content
+ data := []byte(url)
+ if url != "" {
+ data = append(data, "\n\n"...)
+ }
+ data = append(data, content...)
+
+ return os.WriteFile(filePath, data, 0o600)
+}
@@ -0,0 +1,90 @@
+// Code generated by "stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go"; DO NOT EDIT.
+
+package llm
+
+import "strconv"
+
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[MessageRoleUser-0]
+ _ = x[MessageRoleAssistant-1]
+}
+
+const _MessageRole_name = "MessageRoleUserMessageRoleAssistant"
+
+var _MessageRole_index = [...]uint8{0, 15, 35}
+
+func (i MessageRole) String() string {
+ idx := int(i) - 0
+ if i < 0 || idx >= len(_MessageRole_index)-1 {
+ return "MessageRole(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _MessageRole_name[_MessageRole_index[idx]:_MessageRole_index[idx+1]]
+}
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[ContentTypeText-2]
+ _ = x[ContentTypeThinking-3]
+ _ = x[ContentTypeRedactedThinking-4]
+ _ = x[ContentTypeToolUse-5]
+ _ = x[ContentTypeToolResult-6]
+}
+
+const _ContentType_name = "ContentTypeTextContentTypeThinkingContentTypeRedactedThinkingContentTypeToolUseContentTypeToolResult"
+
+var _ContentType_index = [...]uint8{0, 15, 34, 61, 79, 100}
+
+func (i ContentType) String() string {
+ idx := int(i) - 2
+ if i < 2 || idx >= len(_ContentType_index)-1 {
+ return "ContentType(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _ContentType_name[_ContentType_index[idx]:_ContentType_index[idx+1]]
+}
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[ToolChoiceTypeAuto-7]
+ _ = x[ToolChoiceTypeAny-8]
+ _ = x[ToolChoiceTypeNone-9]
+ _ = x[ToolChoiceTypeTool-10]
+}
+
+const _ToolChoiceType_name = "ToolChoiceTypeAutoToolChoiceTypeAnyToolChoiceTypeNoneToolChoiceTypeTool"
+
+var _ToolChoiceType_index = [...]uint8{0, 18, 35, 53, 71}
+
+func (i ToolChoiceType) String() string {
+ idx := int(i) - 7
+ if i < 7 || idx >= len(_ToolChoiceType_index)-1 {
+ return "ToolChoiceType(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _ToolChoiceType_name[_ToolChoiceType_index[idx]:_ToolChoiceType_index[idx+1]]
+}
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[StopReasonStopSequence-11]
+ _ = x[StopReasonMaxTokens-12]
+ _ = x[StopReasonEndTurn-13]
+ _ = x[StopReasonToolUse-14]
+ _ = x[StopReasonRefusal-15]
+}
+
+const _StopReason_name = "StopReasonStopSequenceStopReasonMaxTokensStopReasonEndTurnStopReasonToolUseStopReasonRefusal"
+
+var _StopReason_index = [...]uint8{0, 22, 41, 58, 75, 92}
+
+func (i StopReason) String() string {
+ idx := int(i) - 11
+ if i < 11 || idx >= len(_StopReason_index)-1 {
+ return "StopReason(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _StopReason_name[_StopReason_index[idx]:_StopReason_index[idx+1]]
+}
@@ -0,0 +1,918 @@
+package oai
+
+import (
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math/rand/v2"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sashabaranov/go-openai"
+ "shelley.exe.dev/llm"
+)
+
+const (
+ DefaultMaxTokens = 8192
+
+ OpenAIURL = "https://api.openai.com/v1"
+ FireworksURL = "https://api.fireworks.ai/inference/v1"
+ CerebrasURL = "https://api.cerebras.ai/v1"
+ LlamaCPPURL = "http://host.docker.internal:1234/v1"
+ TogetherURL = "https://api.together.xyz/v1"
+ GeminiURL = "https://generativelanguage.googleapis.com/v1beta/openai/"
+ MistralURL = "https://api.mistral.ai/v1"
+ MoonshotURL = "https://api.moonshot.ai/v1"
+
+ // Environment variable names for API keys
+ OpenAIAPIKeyEnv = "OPENAI_API_KEY"
+ FireworksAPIKeyEnv = "FIREWORKS_API_KEY"
+ CerebrasAPIKeyEnv = "CEREBRAS_API_KEY"
+ TogetherAPIKeyEnv = "TOGETHER_API_KEY"
+ GeminiAPIKeyEnv = "GEMINI_API_KEY"
+ MistralAPIKeyEnv = "MISTRAL_API_KEY"
+ MoonshotAPIKeyEnv = "MOONSHOT_API_KEY"
+)
+
+type Model struct {
+ UserName string // provided by the user to identify this model (e.g. "gpt4.1")
+ ModelName string // provided to the service provide to specify which model to use (e.g. "gpt-4.1-2025-04-14")
+ URL string
+ APIKeyEnv string // environment variable name for the API key
+ IsReasoningModel bool // whether this model is a reasoning model (e.g. O3, O4-mini)
+ UseSimplifiedPatch bool // whether to use the simplified patch input schema; defaults to false
+}
+
+var (
+ DefaultModel = GPT41
+
+ GPT41 = Model{
+ UserName: "gpt4.1",
+ ModelName: "gpt-4.1-2025-04-14",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT4o = Model{
+ UserName: "gpt4o",
+ ModelName: "gpt-4o-2024-08-06",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT4oMini = Model{
+ UserName: "gpt4o-mini",
+ ModelName: "gpt-4o-mini-2024-07-18",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT41Mini = Model{
+ UserName: "gpt4.1-mini",
+ ModelName: "gpt-4.1-mini-2025-04-14",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT41Nano = Model{
+ UserName: "gpt4.1-nano",
+ ModelName: "gpt-4.1-nano-2025-04-14",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ O3 = Model{
+ UserName: "o3",
+ ModelName: "o3-2025-04-16",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ IsReasoningModel: true,
+ }
+
+ O4Mini = Model{
+ UserName: "o4-mini",
+ ModelName: "o4-mini-2025-04-16",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ IsReasoningModel: true,
+ }
+
+ Gemini25Flash = Model{
+ UserName: "gemini-flash-2.5",
+ ModelName: "gemini-2.5-flash-preview-04-17",
+ URL: GeminiURL,
+ APIKeyEnv: GeminiAPIKeyEnv,
+ }
+
+ Gemini25Pro = Model{
+ UserName: "gemini-pro-2.5",
+ ModelName: "gemini-2.5-pro-preview-03-25",
+ URL: GeminiURL,
+ // GRRRR. Really??
+ // Input is: $1.25, prompts <= 200k tokens, $2.50, prompts > 200k tokens
+ // Output is: $10.00, prompts <= 200k tokens, $15.00, prompts > 200k
+ // Caching is: $0.31, prompts <= 200k tokens, $0.625, prompts > 200k, $4.50 / 1,000,000 tokens per hour
+ // Whatever that means. Are we caching? I have no idea.
+ // How do you always manage to be the annoying one, Google?
+ // I'm not complicating things just for you.
+ APIKeyEnv: GeminiAPIKeyEnv,
+ }
+
+ TogetherDeepseekV3 = Model{
+ UserName: "together-deepseek-v3",
+ ModelName: "deepseek-ai/DeepSeek-V3",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ TogetherDeepseekR1 = Model{
+ UserName: "together-deepseek-r1",
+ ModelName: "deepseek-ai/DeepSeek-R1",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ TogetherLlama4Maverick = Model{
+ UserName: "together-llama4-maverick",
+ ModelName: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ FireworksLlama4Maverick = Model{
+ UserName: "fireworks-llama4-maverick",
+ ModelName: "accounts/fireworks/models/llama4-maverick-instruct-basic",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ TogetherLlama3_3_70B = Model{
+ UserName: "together-llama3-70b",
+ ModelName: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ TogetherMistralSmall = Model{
+ UserName: "together-mistral-small",
+ ModelName: "mistralai/Mistral-Small-24B-Instruct-2501",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ TogetherQwen3 = Model{
+ UserName: "together-qwen3",
+ ModelName: "Qwen/Qwen3-235B-A22B-fp8-tput",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ TogetherGemma2 = Model{
+ UserName: "together-gemma2",
+ ModelName: "google/gemma-2-27b-it",
+ URL: TogetherURL,
+ APIKeyEnv: TogetherAPIKeyEnv,
+ }
+
+ LlamaCPP = Model{
+ UserName: "llama.cpp",
+ ModelName: "llama.cpp local model",
+ URL: LlamaCPPURL,
+ APIKeyEnv: "NONE",
+ }
+
+ FireworksDeepseekV3 = Model{
+ UserName: "fireworks-deepseek-v3",
+ ModelName: "accounts/fireworks/models/deepseek-v3-0324",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ MoonshotKimiK2 = Model{
+ UserName: "moonshot-kimi-k2",
+ ModelName: "moonshot-v1-auto",
+ URL: MoonshotURL,
+ APIKeyEnv: MoonshotAPIKeyEnv,
+ }
+
+ MistralMedium = Model{
+ UserName: "mistral-medium-3",
+ ModelName: "mistral-medium-latest",
+ URL: MistralURL,
+ APIKeyEnv: MistralAPIKeyEnv,
+ }
+
+ DevstralSmall = Model{
+ UserName: "devstral-small",
+ ModelName: "devstral-small-latest",
+ URL: MistralURL,
+ APIKeyEnv: MistralAPIKeyEnv,
+ }
+
+ Qwen3CoderFireworks = Model{
+ UserName: "qwen3-coder-fireworks",
+ ModelName: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ UseSimplifiedPatch: true,
+ }
+
+ Qwen3CoderCerebras = Model{
+ UserName: "qwen3-coder-cerebras",
+ ModelName: "qwen-3-coder-480b",
+ URL: CerebrasURL,
+ APIKeyEnv: CerebrasAPIKeyEnv,
+ }
+
+ Qwen3Coder30Fireworks = Model{
+ UserName: "qwen3-coder-30-fireworks",
+ ModelName: "accounts/fireworks/models/qwen3-30b-a3b",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ UseSimplifiedPatch: true,
+ }
+
+ ZaiGLM45CoderFireworks = Model{
+ UserName: "zai-glm45-fireworks",
+ ModelName: "accounts/fireworks/models/glm-4p5",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ GLM4P6Fireworks = Model{
+ UserName: "glm-4p6-fireworks",
+ ModelName: "accounts/fireworks/models/glm-4p6",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ GPTOSS20B = Model{
+ UserName: "gpt-oss-20b",
+ ModelName: "accounts/fireworks/models/gpt-oss-20b",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ GPTOSS120B = Model{
+ UserName: "gpt-oss-120b",
+ ModelName: "accounts/fireworks/models/gpt-oss-120b",
+ URL: FireworksURL,
+ APIKeyEnv: FireworksAPIKeyEnv,
+ }
+
+ GPT5 = Model{
+ UserName: "gpt-5-thinking",
+ ModelName: "gpt-5.1",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT5Mini = Model{
+ UserName: "gpt-5-thinking-mini",
+ ModelName: "gpt-5.1-mini",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT5Nano = Model{
+ UserName: "gpt-5-thinking-nano",
+ ModelName: "gpt-5.1-nano",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ GPT5Codex = Model{
+ UserName: "gpt-5.1-codex",
+ ModelName: "gpt-5.1-codex",
+ URL: OpenAIURL,
+ APIKeyEnv: OpenAIAPIKeyEnv,
+ }
+
+ // Skaband-specific model names.
+ // Provider details (URL and APIKeyEnv) are handled by skaband
+ Qwen = Model{
+ UserName: "qwen",
+ ModelName: "qwen", // skaband will map this to the actual provider model
+ UseSimplifiedPatch: true,
+ }
+ GLM = Model{
+ UserName: "glm",
+ ModelName: "glm", // skaband will map this to the actual provider model
+ }
+)
+
+// Service provides chat completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ APIKey string // optional, if not set will try to load from env var
+ Model Model // defaults to DefaultModel if zero value
+ ModelURL string // optional, overrides Model.URL
+ MaxTokens int // defaults to DefaultMaxTokens if zero
+ Org string // optional - organization ID
+ DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*Service)(nil)
+
+// ModelsRegistry is a registry of all known models with their user-friendly names.
+var ModelsRegistry = []Model{
+ GPT41,
+ GPT41Mini,
+ GPT41Nano,
+ GPT4o,
+ GPT4oMini,
+ GPT5,
+ GPT5Mini,
+ GPT5Nano,
+ GPT5Codex,
+ O3,
+ O4Mini,
+ Gemini25Flash,
+ Gemini25Pro,
+ TogetherDeepseekV3,
+ TogetherDeepseekR1,
+ TogetherLlama4Maverick,
+ TogetherLlama3_3_70B,
+ TogetherMistralSmall,
+ TogetherQwen3,
+ TogetherGemma2,
+ LlamaCPP,
+ FireworksDeepseekV3,
+ MoonshotKimiK2,
+ FireworksLlama4Maverick,
+ MistralMedium,
+ DevstralSmall,
+ Qwen3CoderFireworks,
+ Qwen3Coder30Fireworks,
+ Qwen3CoderCerebras,
+ ZaiGLM45CoderFireworks,
+ GLM4P6Fireworks,
+ GPTOSS120B,
+ GPTOSS20B,
+ // Skaband-supported models
+ Qwen,
+ GLM,
+}
+
+// ListModels returns a list of all available models with their user-friendly names.
+func ListModels() []string {
+ var names []string
+ for _, model := range ModelsRegistry {
+ if model.UserName != "" {
+ names = append(names, model.UserName)
+ }
+ }
+ return names
+}
+
+// ModelByUserName returns a model by its user-friendly name.
+// Returns nil if no model with the given name is found.
+func ModelByUserName(name string) Model {
+ for _, model := range ModelsRegistry {
+ if model.UserName == name {
+ return model
+ }
+ }
+ return Model{}
+}
+
+func (m Model) IsZero() bool {
+ return m == Model{}
+}
+
+var (
+ fromLLMRole = map[llm.MessageRole]string{
+ llm.MessageRoleAssistant: "assistant",
+ llm.MessageRoleUser: "user",
+ }
+ fromLLMToolChoiceType = map[llm.ToolChoiceType]string{
+ llm.ToolChoiceTypeAuto: "auto",
+ llm.ToolChoiceTypeAny: "any",
+ llm.ToolChoiceTypeNone: "none",
+ llm.ToolChoiceTypeTool: "function", // OpenAI uses "function" instead of "tool"
+ }
+ toLLMRole = map[string]llm.MessageRole{
+ "assistant": llm.MessageRoleAssistant,
+ "user": llm.MessageRoleUser,
+ }
+ toLLMStopReason = map[string]llm.StopReason{
+ "stop": llm.StopReasonStopSequence,
+ "length": llm.StopReasonMaxTokens,
+ "tool_calls": llm.StopReasonToolUse,
+ "function_call": llm.StopReasonToolUse, // Map both to ToolUse
+ "content_filter": llm.StopReasonStopSequence, // No direct equivalent
+ }
+)
+
+// fromLLMContent converts llm.Content to the format expected by OpenAI.
+func fromLLMContent(c llm.Content) (string, []openai.ToolCall) {
+ switch c.Type {
+ case llm.ContentTypeText:
+ return c.Text, nil
+ case llm.ContentTypeToolUse:
+ // For OpenAI, tool use is sent as a null content with tool_calls in the message
+ return "", []openai.ToolCall{
+ {
+ Type: openai.ToolTypeFunction,
+ ID: c.ID, // Use the content ID if provided
+ Function: openai.FunctionCall{
+ Name: c.ToolName,
+ Arguments: string(c.ToolInput),
+ },
+ },
+ }
+ case llm.ContentTypeToolResult:
+ // Tool results in OpenAI are sent as a separate message with tool_call_id
+ // OpenAI doesn't support multiple content items or images in tool results
+ // Combine all text content into a single string
+ var resultText string
+ if len(c.ToolResult) > 0 {
+ // Collect all text from content objects
+ texts := make([]string, 0, len(c.ToolResult))
+ for _, result := range c.ToolResult {
+ if result.Text != "" {
+ texts = append(texts, result.Text)
+ }
+ }
+ resultText = strings.Join(texts, "\n")
+ }
+ return resultText, nil
+ default:
+ // For thinking or other types, convert to text
+ return c.Text, nil
+ }
+}
+
+// fromLLMMessage converts llm.Message to OpenAI ChatCompletionMessage format
+func fromLLMMessage(msg llm.Message) []openai.ChatCompletionMessage {
+ // For OpenAI, we need to handle tool results differently than regular messages
+ // Each tool result becomes its own message with role="tool"
+
+ var messages []openai.ChatCompletionMessage
+
+ // Check if this is a regular message or contains tool results
+ var regularContent []llm.Content
+ var toolResults []llm.Content
+
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult {
+ toolResults = append(toolResults, c)
+ } else {
+ regularContent = append(regularContent, c)
+ }
+ }
+
+ // Process tool results as separate messages, but first
+ for _, tr := range toolResults {
+ // Convert toolresult array to a string for OpenAI
+ // Collect all text from content objects
+ var texts []string
+ for _, result := range tr.ToolResult {
+ if strings.TrimSpace(result.Text) != "" {
+ texts = append(texts, result.Text)
+ }
+ }
+ toolResultContent := strings.Join(texts, "\n")
+
+ // OpenAI doesn't have an explicit error field for tool results, so add it directly to the content.
+ if tr.ToolError {
+ if toolResultContent != "" {
+ toolResultContent = "error: " + toolResultContent
+ } else {
+ toolResultContent = "error: tool execution failed"
+ }
+ }
+
+ m := openai.ChatCompletionMessage{
+ Role: "tool",
+ Content: cmp.Or(toolResultContent, " "), // Use empty space if empty to avoid omitempty issues
+ ToolCallID: tr.ToolUseID,
+ }
+ messages = append(messages, m)
+ }
+ // Process regular content second
+ if len(regularContent) > 0 {
+ m := openai.ChatCompletionMessage{
+ Role: fromLLMRole[msg.Role],
+ }
+
+ // For assistant messages that contain tool calls
+ var toolCalls []openai.ToolCall
+ var textContent string
+
+ for _, c := range regularContent {
+ content, tools := fromLLMContent(c)
+ if len(tools) > 0 {
+ toolCalls = append(toolCalls, tools...)
+ } else if content != "" {
+ if textContent != "" {
+ textContent += "\n"
+ }
+ textContent += content
+ }
+ }
+
+ m.Content = textContent
+ m.ToolCalls = toolCalls
+
+ messages = append(messages, m)
+ }
+
+ return messages
+}
+
+// requiresMaxCompletionTokens returns true if the model requires max_completion_tokens instead of max_tokens.
+func (m Model) requiresMaxCompletionTokens() bool {
+ // Reasoning models always use max_completion_tokens
+ if m.IsReasoningModel {
+ return true
+ }
+
+ // GPT-5 series models also require max_completion_tokens
+ switch m.ModelName {
+ case "gpt-5.1", "gpt-5.1-mini", "gpt-5.1-nano":
+ return true
+ default:
+ return false
+ }
+}
+
+// fromLLMToolChoice converts llm.ToolChoice to the format expected by OpenAI.
+func fromLLMToolChoice(tc *llm.ToolChoice) any {
+ if tc == nil {
+ return nil
+ }
+
+ if tc.Type == llm.ToolChoiceTypeTool && tc.Name != "" {
+ return openai.ToolChoice{
+ Type: openai.ToolTypeFunction,
+ Function: openai.ToolFunction{
+ Name: tc.Name,
+ },
+ }
+ }
+
+ // For non-specific tool choice, just use the string
+ return fromLLMToolChoiceType[tc.Type]
+}
+
+// fromLLMTool converts llm.Tool to the format expected by OpenAI.
+func fromLLMTool(t *llm.Tool) openai.Tool {
+ return openai.Tool{
+ Type: openai.ToolTypeFunction,
+ Function: &openai.FunctionDefinition{
+ Name: t.Name,
+ Description: t.Description,
+ Parameters: t.InputSchema,
+ },
+ }
+}
+
+// fromLLMSystem converts llm.SystemContent to an OpenAI system message.
+func fromLLMSystem(systemContent []llm.SystemContent) []openai.ChatCompletionMessage {
+ if len(systemContent) == 0 {
+ return nil
+ }
+
+ // Combine all system content into a single system message
+ var systemText string
+ for i, content := range systemContent {
+ if i > 0 && systemText != "" && content.Text != "" {
+ systemText += "\n"
+ }
+ systemText += content.Text
+ }
+
+ if systemText == "" {
+ return nil
+ }
+
+ return []openai.ChatCompletionMessage{
+ {
+ Role: "system",
+ Content: systemText,
+ },
+ }
+}
+
+// toRawLLMContent converts a raw content string from OpenAI to llm.Content.
+func toRawLLMContent(content string) llm.Content {
+ return llm.Content{
+ Type: llm.ContentTypeText,
+ Text: content,
+ }
+}
+
+// toToolCallLLMContent converts a tool call from OpenAI to llm.Content.
+func toToolCallLLMContent(toolCall openai.ToolCall) llm.Content {
+ // Generate a content ID if needed
+ id := toolCall.ID
+ if id == "" {
+ // Create a deterministic ID based on the function name if no ID is provided
+ id = "tc_" + toolCall.Function.Name
+ }
+
+ return llm.Content{
+ ID: id,
+ Type: llm.ContentTypeToolUse,
+ ToolName: toolCall.Function.Name,
+ ToolInput: json.RawMessage(toolCall.Function.Arguments),
+ }
+}
+
+// toToolResultLLMContent converts a tool result message from OpenAI to llm.Content.
+func toToolResultLLMContent(msg openai.ChatCompletionMessage) llm.Content {
+ return llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: msg.ToolCallID,
+ ToolResult: []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: msg.Content,
+ }},
+ ToolError: false, // OpenAI doesn't specify errors explicitly; error information is parsed from content
+ }
+}
+
+// toLLMContents converts message content from OpenAI to []llm.Content.
+func toLLMContents(msg openai.ChatCompletionMessage) []llm.Content {
+ var contents []llm.Content
+
+ // If this is a tool response, handle it separately
+ if msg.Role == "tool" && msg.ToolCallID != "" {
+ return []llm.Content{toToolResultLLMContent(msg)}
+ }
+
+ // If there's text content, add it
+ if msg.Content != "" {
+ contents = append(contents, toRawLLMContent(msg.Content))
+ }
+
+ // If there are tool calls, add them
+ for _, tc := range msg.ToolCalls {
+ contents = append(contents, toToolCallLLMContent(tc))
+ }
+
+ // If empty, add an empty text content
+ if len(contents) == 0 {
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeText,
+ Text: "",
+ })
+ }
+
+ return contents
+}
+
+// toLLMUsage converts usage information from OpenAI to llm.Usage.
+func (s *Service) toLLMUsage(au openai.Usage, headers http.Header) llm.Usage {
+ // fmt.Printf("raw usage: %+v / %v / %v\n", au, au.PromptTokensDetails, au.CompletionTokensDetails)
+ in := uint64(au.PromptTokens)
+ var inc uint64
+ if au.PromptTokensDetails != nil {
+ inc = uint64(au.PromptTokensDetails.CachedTokens)
+ }
+ out := uint64(au.CompletionTokens)
+ u := llm.Usage{
+ InputTokens: in,
+ CacheReadInputTokens: inc,
+ CacheCreationInputTokens: in,
+ OutputTokens: out,
+ }
+ u.CostUSD = llm.CostUSDFromResponse(headers)
+ return u
+}
+
+// toLLMResponse converts the OpenAI response to llm.Response.
+func (s *Service) toLLMResponse(r *openai.ChatCompletionResponse) *llm.Response {
+ // fmt.Printf("Raw response\n")
+ // enc := json.NewEncoder(os.Stdout)
+ // enc.SetIndent("", " ")
+ // enc.Encode(r)
+ // fmt.Printf("\n")
+
+ if len(r.Choices) == 0 {
+ return &llm.Response{
+ ID: r.ID,
+ Model: r.Model,
+ Role: llm.MessageRoleAssistant,
+ Usage: s.toLLMUsage(r.Usage, r.Header()),
+ }
+ }
+
+ // Process the primary choice
+ choice := r.Choices[0]
+
+ return &llm.Response{
+ ID: r.ID,
+ Model: r.Model,
+ Role: toRoleFromString(choice.Message.Role),
+ Content: toLLMContents(choice.Message),
+ StopReason: toStopReason(string(choice.FinishReason)),
+ Usage: s.toLLMUsage(r.Usage, r.Header()),
+ }
+}
+
+// toRoleFromString converts a role string to llm.MessageRole.
+func toRoleFromString(role string) llm.MessageRole {
+ if role == "tool" || role == "system" || role == "function" {
+ return llm.MessageRoleAssistant // Map special roles to assistant for consistency
+ }
+ if mr, ok := toLLMRole[role]; ok {
+ return mr
+ }
+ return llm.MessageRoleUser // Default to user if unknown
+}
+
+// toStopReason converts a finish reason string to llm.StopReason.
+func toStopReason(reason string) llm.StopReason {
+ if sr, ok := toLLMStopReason[reason]; ok {
+ return sr
+ }
+ return llm.StopReasonStopSequence // Default
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+ // TODO: move TokenContextWindow information to Model struct
+
+ model := cmp.Or(s.Model, DefaultModel)
+
+ // OpenAI models generally have 128k context windows
+ // Some newer models have larger windows, but 128k is a safe default
+ switch model.ModelName {
+ case "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14":
+ return 200000 // 200k for newer GPT-4.1 models
+ case "gpt-4o-2024-08-06", "gpt-4o-mini-2024-07-18":
+ return 128000 // 128k for GPT-4o models
+ case "o3-2025-04-16", "o3-mini-2025-04-16":
+ return 200000 // 200k for O3 models
+ case "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":
+ return 256000 // 256k native context for Qwen3-Coder
+ case "glm", "zai-glm45-fireworks":
+ return 128000
+ case "qwen", "qwen3-coder-cerebras", "qwen3-coder-fireworks":
+ return 256000 // 256k native context for Qwen3-Coder
+ case "gpt-oss-20b", "gpt-oss-120b":
+ return 128000
+ case "gpt-5.1", "gpt-5.1-mini", "gpt-5.1-nano":
+ return 256000
+ default:
+ // Default for unknown models
+ return 128000
+ }
+}
+
+// Do sends a request to OpenAI using the go-openai package.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+ // Configure the OpenAI client
+ httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+ model := cmp.Or(s.Model, DefaultModel)
+
+ // TODO: do this one during Service setup? maybe with a constructor instead?
+ config := openai.DefaultConfig(s.APIKey)
+ baseURL := cmp.Or(s.ModelURL, model.URL)
+ if baseURL != "" {
+ config.BaseURL = baseURL
+ }
+ if s.Org != "" {
+ config.OrgID = s.Org
+ }
+ config.HTTPClient = httpc
+
+ client := openai.NewClientWithConfig(config)
+
+ // Start with system messages if provided
+ var allMessages []openai.ChatCompletionMessage
+ if len(ir.System) > 0 {
+ sysMessages := fromLLMSystem(ir.System)
+ allMessages = append(allMessages, sysMessages...)
+ }
+
+ // Add regular and tool messages
+ for _, msg := range ir.Messages {
+ msgs := fromLLMMessage(msg)
+ allMessages = append(allMessages, msgs...)
+ }
+
+ // Convert tools
+ var tools []openai.Tool
+ for _, t := range ir.Tools {
+ tools = append(tools, fromLLMTool(t))
+ }
+
+ // Create the OpenAI request
+ req := openai.ChatCompletionRequest{
+ Model: model.ModelName,
+ Messages: allMessages,
+ Tools: tools,
+ ToolChoice: fromLLMToolChoice(ir.ToolChoice), // TODO: make fromLLMToolChoice return an error when a perfect translation is not possible
+ }
+ if model.requiresMaxCompletionTokens() {
+ req.MaxCompletionTokens = cmp.Or(s.MaxTokens, DefaultMaxTokens)
+ } else {
+ req.MaxTokens = cmp.Or(s.MaxTokens, DefaultMaxTokens)
+ }
+ // Construct the full URL for logging and debugging
+ fullURL := baseURL + "/chat/completions"
+
+ // Dump request if enabled
+ if s.DumpLLM {
+ if reqJSON, err := json.MarshalIndent(req, "", " "); err == nil {
+ if err := llm.DumpToFile("request", fullURL, reqJSON); err != nil {
+ slog.WarnContext(ctx, "failed to dump openai request to file", "error", err)
+ }
+ }
+ }
+
+ // Retry mechanism
+ backoff := []time.Duration{1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second}
+
+ // retry loop
+ var errs error // accumulated errors across all attempts
+ for attempts := 0; ; attempts++ {
+ if attempts > 10 {
+ return nil, fmt.Errorf("openai request failed after %d attempts (url=%s, model=%s): %w", attempts, fullURL, model.ModelName, errs)
+ }
+ if attempts > 0 {
+ sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+ slog.WarnContext(ctx, "openai request sleep before retry", "sleep", sleep, "attempts", attempts)
+ time.Sleep(sleep)
+ }
+
+ resp, err := client.CreateChatCompletion(ctx, req)
+
+ // Handle successful response
+ if err == nil {
+ // Dump response if enabled
+ if s.DumpLLM {
+ if respJSON, jsonErr := json.MarshalIndent(resp, "", " "); jsonErr == nil {
+ if dumpErr := llm.DumpToFile("response", "", respJSON); dumpErr != nil {
+ slog.WarnContext(ctx, "failed to dump openai response to file", "error", dumpErr)
+ }
+ }
+ }
+ return s.toLLMResponse(&resp), nil
+ }
+
+ // Handle errors
+ // Check for TLS "bad record MAC" errors and retry once
+ if strings.Contains(err.Error(), "tls: bad record MAC") && attempts == 0 {
+ slog.WarnContext(ctx, "tls bad record MAC error, retrying once", "error", err.Error())
+ errs = errors.Join(errs, fmt.Errorf("TLS error (attempt %d): %w", attempts+1, err))
+ continue
+ }
+
+ var apiErr *openai.APIError
+ if ok := errors.As(err, &apiErr); !ok {
+ // Not an OpenAI API error, return immediately with accumulated errors
+ return nil, errors.Join(errs, fmt.Errorf("url=%s model=%s: %w", fullURL, model.ModelName, err))
+ }
+
+ switch {
+ case apiErr.HTTPStatusCode >= 500:
+ // Server error, try again with backoff
+ slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+ errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+ continue
+
+ case apiErr.HTTPStatusCode == 429:
+ // Rate limited, accumulate error and retry
+ slog.WarnContext(ctx, "openai_request_rate_limited", "error", apiErr.Error(), "url", fullURL, "model", model.ModelName)
+ errs = errors.Join(errs, fmt.Errorf("status %d (rate limited, url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+ continue
+
+ case apiErr.HTTPStatusCode >= 400 && apiErr.HTTPStatusCode < 500:
+ // Client error, probably unrecoverable
+ slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+ return nil, errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+
+ default:
+ // Other error, accumulate and retry
+ slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+ errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+ continue
+ }
+ }
+}
+
+func (s *Service) UseSimplifiedPatch() bool {
+ return s.Model.UseSimplifiedPatch
+}
+
+// ConfigDetails returns configuration information for logging
+func (s *Service) ConfigDetails() map[string]string {
+ model := cmp.Or(s.Model, DefaultModel)
+ baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+ return map[string]string{
+ "base_url": baseURL,
+ "model_name": model.ModelName,
+ "full_url": baseURL + "/chat/completions",
+ "api_key_env": model.APIKeyEnv,
+ "has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+ }
+}
@@ -0,0 +1,522 @@
+package oai
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "math/rand/v2"
+ "net/http"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/llm"
+)
+
+// ResponsesService provides chat completions using the OpenAI Responses API.
+// This API is required for models like gpt-5.1-codex.
+// Fields should not be altered concurrently with calling any method on ResponsesService.
+type ResponsesService struct {
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ APIKey string // optional, if not set will try to load from env var
+ Model Model // defaults to DefaultModel if zero value
+ ModelURL string // optional, overrides Model.URL
+ MaxTokens int // defaults to DefaultMaxTokens if zero
+ Org string // optional - organization ID
+ DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*ResponsesService)(nil)
+
+// Responses API request/response types
+
+type responsesRequest struct {
+ Model string `json:"model"`
+ Input []responsesInputItem `json:"input"`
+ Tools []responsesTool `json:"tools,omitempty"`
+ ToolChoice any `json:"tool_choice,omitempty"`
+ MaxOutputTokens int `json:"max_output_tokens,omitempty"`
+ Reasoning *responsesReasoning `json:"reasoning,omitempty"`
+}
+
+type responsesReasoning struct {
+ Effort string `json:"effort,omitempty"` // "low", "medium", "high"
+}
+
+type responsesInputItem struct {
+ Type string `json:"type"` // "message", "function_call", "function_call_output"
+ Role string `json:"role,omitempty"` // for messages: "user", "assistant"
+ Content []responsesContent `json:"content,omitempty"` // for messages
+ CallID string `json:"call_id,omitempty"` // for function_call and function_call_output
+ Name string `json:"name,omitempty"` // for function_call
+ Arguments string `json:"arguments,omitempty"` // for function_call
+ Output string `json:"output,omitempty"` // for function_call_output
+}
+
+type responsesContent struct {
+ Type string `json:"type"` // "input_text", "output_text"
+ Text string `json:"text"`
+}
+
+type responsesTool struct {
+ Type string `json:"type"` // "function"
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters json.RawMessage `json:"parameters,omitempty"`
+}
+
+type responsesResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"` // "response"
+ CreatedAt int64 `json:"created_at"`
+ Status string `json:"status"` // "completed", "incomplete", etc.
+ Model string `json:"model"`
+ Output []responsesOutputItem `json:"output"`
+ Usage responsesUsage `json:"usage"`
+ Error *responsesError `json:"error"`
+}
+
+type responsesOutputItem struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // "message", "reasoning", "function_call"
+ Role string `json:"role,omitempty"` // for messages: "assistant"
+ Status string `json:"status,omitempty"`
+ Content []responsesContent `json:"content,omitempty"` // for messages
+ CallID string `json:"call_id,omitempty"` // for function_call
+ Name string `json:"name,omitempty"` // for function_call
+ Arguments string `json:"arguments,omitempty"` // for function_call
+ Summary []string `json:"summary,omitempty"` // for reasoning
+}
+
+type responsesUsage struct {
+ InputTokens int `json:"input_tokens"`
+ InputTokensDetails *responsesInputTokensDetails `json:"input_tokens_details,omitempty"`
+ OutputTokens int `json:"output_tokens"`
+ OutputTokensDetails *responsesOutputTokensDetails `json:"output_tokens_details,omitempty"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type responsesInputTokensDetails struct {
+ CachedTokens int `json:"cached_tokens"`
+}
+
+type responsesOutputTokensDetails struct {
+ ReasoningTokens int `json:"reasoning_tokens"`
+}
+
+type responsesError struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Param string `json:"param"`
+ Code string `json:"code"`
+}
+
+// fromLLMMessageResponses converts llm.Message to Responses API input items
+func fromLLMMessageResponses(msg llm.Message) []responsesInputItem {
+ var items []responsesInputItem
+
+ // Separate tool results from regular content
+ var regularContent []llm.Content
+ var toolResults []llm.Content
+
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult {
+ toolResults = append(toolResults, c)
+ } else {
+ regularContent = append(regularContent, c)
+ }
+ }
+
+ // Process tool results first - they need to come before the assistant message
+ for _, tr := range toolResults {
+ // Collect all text from content objects
+ var texts []string
+ for _, result := range tr.ToolResult {
+ if strings.TrimSpace(result.Text) != "" {
+ texts = append(texts, result.Text)
+ }
+ }
+ toolResultContent := strings.Join(texts, "\n")
+
+ // Add error prefix if needed
+ if tr.ToolError {
+ if toolResultContent != "" {
+ toolResultContent = "error: " + toolResultContent
+ } else {
+ toolResultContent = "error: tool execution failed"
+ }
+ }
+
+ items = append(items, responsesInputItem{
+ Type: "function_call_output",
+ CallID: tr.ToolUseID,
+ Output: cmp.Or(toolResultContent, " "),
+ })
+ }
+
+ // Process regular content
+ if len(regularContent) > 0 {
+ var messageContent []responsesContent
+ var functionCalls []responsesInputItem
+
+ for _, c := range regularContent {
+ switch c.Type {
+ case llm.ContentTypeText:
+ if c.Text != "" {
+ contentType := "input_text"
+ if msg.Role == llm.MessageRoleAssistant {
+ contentType = "output_text"
+ }
+ messageContent = append(messageContent, responsesContent{
+ Type: contentType,
+ Text: c.Text,
+ })
+ }
+ case llm.ContentTypeToolUse:
+ // Tool use becomes a function_call in the input
+ functionCalls = append(functionCalls, responsesInputItem{
+ Type: "function_call",
+ CallID: c.ID,
+ Name: c.ToolName,
+ Arguments: string(c.ToolInput),
+ })
+ }
+ }
+
+ // Add message if it has content
+ if len(messageContent) > 0 {
+ role := "user"
+ if msg.Role == llm.MessageRoleAssistant {
+ role = "assistant"
+ }
+ items = append(items, responsesInputItem{
+ Type: "message",
+ Role: role,
+ Content: messageContent,
+ })
+ }
+
+ // Add function calls
+ items = append(items, functionCalls...)
+ }
+
+ return items
+}
+
+// fromLLMToolResponses converts llm.Tool to Responses API tool format
+func fromLLMToolResponses(t *llm.Tool) responsesTool {
+ return responsesTool{
+ Type: "function",
+ Name: t.Name,
+ Description: t.Description,
+ Parameters: t.InputSchema,
+ }
+}
+
+// fromLLMSystemResponses converts llm.SystemContent to Responses API input items
+func fromLLMSystemResponses(systemContent []llm.SystemContent) []responsesInputItem {
+ if len(systemContent) == 0 {
+ return nil
+ }
+
+ // Combine all system content into a single system message
+ var systemText string
+ for i, content := range systemContent {
+ if i > 0 && systemText != "" && content.Text != "" {
+ systemText += "\n"
+ }
+ systemText += content.Text
+ }
+
+ if systemText == "" {
+ return nil
+ }
+
+ return []responsesInputItem{
+ {
+ Type: "message",
+ Role: "user",
+ Content: []responsesContent{
+ {
+ Type: "input_text",
+ Text: systemText,
+ },
+ },
+ },
+ }
+}
+
+// toLLMResponseFromResponses converts Responses API response to llm.Response
+func (s *ResponsesService) toLLMResponseFromResponses(resp *responsesResponse, headers http.Header) *llm.Response {
+ if len(resp.Output) == 0 {
+ return &llm.Response{
+ ID: resp.ID,
+ Model: resp.Model,
+ Role: llm.MessageRoleAssistant,
+ Usage: s.toLLMUsageFromResponses(resp.Usage, headers),
+ }
+ }
+
+ // Process the output items
+ var contents []llm.Content
+ var stopReason llm.StopReason = llm.StopReasonStopSequence
+
+ for _, item := range resp.Output {
+ switch item.Type {
+ case "message":
+ // Convert message content
+ for _, c := range item.Content {
+ if c.Text != "" {
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeText,
+ Text: c.Text,
+ })
+ }
+ }
+ case "reasoning":
+ // Convert reasoning to thinking content
+ if len(item.Summary) > 0 {
+ summaryText := strings.Join(item.Summary, "\n")
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeThinking,
+ Text: summaryText,
+ })
+ }
+ case "function_call":
+ // Convert function call to tool use
+ contents = append(contents, llm.Content{
+ ID: item.CallID,
+ Type: llm.ContentTypeToolUse,
+ ToolName: item.Name,
+ ToolInput: json.RawMessage(item.Arguments),
+ })
+ stopReason = llm.StopReasonToolUse
+ }
+ }
+
+ // If no content, add empty text content
+ if len(contents) == 0 {
+ contents = append(contents, llm.Content{
+ Type: llm.ContentTypeText,
+ Text: "",
+ })
+ }
+
+ return &llm.Response{
+ ID: resp.ID,
+ Model: resp.Model,
+ Role: llm.MessageRoleAssistant,
+ Content: contents,
+ StopReason: stopReason,
+ Usage: s.toLLMUsageFromResponses(resp.Usage, headers),
+ }
+}
+
+// toLLMUsageFromResponses converts Responses API usage to llm.Usage
+func (s *ResponsesService) toLLMUsageFromResponses(usage responsesUsage, headers http.Header) llm.Usage {
+ in := uint64(usage.InputTokens)
+ var inc uint64
+ if usage.InputTokensDetails != nil {
+ inc = uint64(usage.InputTokensDetails.CachedTokens)
+ }
+ out := uint64(usage.OutputTokens)
+ u := llm.Usage{
+ InputTokens: in,
+ CacheReadInputTokens: inc,
+ CacheCreationInputTokens: in,
+ OutputTokens: out,
+ }
+ u.CostUSD = llm.CostUSDFromResponse(headers)
+ return u
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *ResponsesService) TokenContextWindow() int {
+ model := cmp.Or(s.Model, DefaultModel)
+
+ // Use the same context window logic as the regular service
+ switch model.ModelName {
+ case "gpt-5.1-codex":
+ return 256000 // 256k for gpt-5.1-codex
+ case "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14":
+ return 200000
+ case "gpt-4o-2024-08-06", "gpt-4o-mini-2024-07-18":
+ return 128000
+ default:
+ return 128000
+ }
+}
+
+// Do sends a request to OpenAI using the Responses API.
+func (s *ResponsesService) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+ httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+ model := cmp.Or(s.Model, DefaultModel)
+
+ // Start with system messages if provided
+ var allInput []responsesInputItem
+ if len(ir.System) > 0 {
+ sysItems := fromLLMSystemResponses(ir.System)
+ allInput = append(allInput, sysItems...)
+ }
+
+ // Add regular messages
+ for _, msg := range ir.Messages {
+ items := fromLLMMessageResponses(msg)
+ allInput = append(allInput, items...)
+ }
+
+ // Convert tools
+ var tools []responsesTool
+ for _, t := range ir.Tools {
+ tools = append(tools, fromLLMToolResponses(t))
+ }
+
+ // Create the request
+ req := responsesRequest{
+ Model: model.ModelName,
+ Input: allInput,
+ Tools: tools,
+ MaxOutputTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
+ }
+
+ // Add tool choice if specified
+ if ir.ToolChoice != nil {
+ req.ToolChoice = fromLLMToolChoice(ir.ToolChoice)
+ }
+
+ // Construct the full URL
+ baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+ fullURL := baseURL + "/responses"
+
+ // Marshal the request
+ reqJSON, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ // Dump request if enabled
+ if s.DumpLLM {
+ if reqJSONPretty, err := json.MarshalIndent(req, "", " "); err == nil {
+ if err := llm.DumpToFile("request", fullURL, reqJSONPretty); err != nil {
+ slog.WarnContext(ctx, "failed to dump responses request to file", "error", err)
+ }
+ }
+ }
+
+ // Retry mechanism
+ backoff := []time.Duration{1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second}
+
+ // retry loop
+ var errs error // accumulated errors across all attempts
+ for attempts := 0; ; attempts++ {
+ if attempts > 10 {
+ return nil, fmt.Errorf("responses request failed after %d attempts (url=%s, model=%s): %w", attempts, fullURL, model.ModelName, errs)
+ }
+ if attempts > 0 {
+ sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+ slog.WarnContext(ctx, "responses request sleep before retry", "sleep", sleep, "attempts", attempts)
+ time.Sleep(sleep)
+ }
+
+ // Create HTTP request
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(reqJSON))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+s.APIKey)
+ if s.Org != "" {
+ httpReq.Header.Set("OpenAI-Organization", s.Org)
+ }
+
+ // Send request
+ httpResp, err := httpc.Do(httpReq)
+ if err != nil {
+ errs = errors.Join(errs, fmt.Errorf("attempt %d: %w", attempts+1, err))
+ continue
+ }
+ defer httpResp.Body.Close()
+
+ // Read response body
+ body, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Handle non-200 responses
+ if httpResp.StatusCode != http.StatusOK {
+ var apiErr responsesError
+ if jsonErr := json.Unmarshal(body, &struct {
+ Error *responsesError `json:"error"`
+ }{Error: &apiErr}); jsonErr == nil && apiErr.Message != "" {
+ // We have a structured error
+ switch {
+ case httpResp.StatusCode >= 500:
+ // Server error, retry
+ slog.WarnContext(ctx, "responses_request_failed", "error", apiErr.Message, "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName)
+ errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+ continue
+
+ case httpResp.StatusCode == 429:
+ // Rate limited, retry
+ slog.WarnContext(ctx, "responses_request_rate_limited", "error", apiErr.Message, "url", fullURL, "model", model.ModelName)
+ errs = errors.Join(errs, fmt.Errorf("status %d (rate limited, url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+ continue
+
+ case httpResp.StatusCode >= 400 && httpResp.StatusCode < 500:
+ // Client error, probably unrecoverable
+ slog.WarnContext(ctx, "responses_request_failed", "error", apiErr.Message, "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName)
+ return nil, errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+ }
+ }
+
+ // No structured error, use the raw body
+ slog.WarnContext(ctx, "responses_request_failed", "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName, "body", string(body))
+ return nil, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, string(body))
+ }
+
+ // Parse successful response
+ var resp responsesResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ // Check for errors in the response
+ if resp.Error != nil {
+ return nil, fmt.Errorf("response contains error: %s", resp.Error.Message)
+ }
+
+ // Dump response if enabled
+ if s.DumpLLM {
+ if respJSON, err := json.MarshalIndent(resp, "", " "); err == nil {
+ if err := llm.DumpToFile("response", "", respJSON); err != nil {
+ slog.WarnContext(ctx, "failed to dump responses response to file", "error", err)
+ }
+ }
+ }
+
+ return s.toLLMResponseFromResponses(&resp, httpResp.Header), nil
+ }
+}
+
+func (s *ResponsesService) UseSimplifiedPatch() bool {
+ return s.Model.UseSimplifiedPatch
+}
+
+// ConfigDetails returns configuration information for logging
+func (s *ResponsesService) ConfigDetails() map[string]string {
+ model := cmp.Or(s.Model, DefaultModel)
+ baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+ return map[string]string{
+ "base_url": baseURL,
+ "model_name": model.ModelName,
+ "full_url": baseURL + "/responses",
+ "api_key_env": model.APIKeyEnv,
+ "has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+ }
+}
@@ -0,0 +1,415 @@
+package oai
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+
+ "shelley.exe.dev/llm"
+)
+
+func TestResponsesServiceBasic(t *testing.T) {
+ // This is a basic compile-time test to ensure ResponsesService implements llm.Service
+ var _ llm.Service = (*ResponsesService)(nil)
+}
+
+func TestFromLLMMessageResponses(t *testing.T) {
+ tests := []struct {
+ name string
+ msg llm.Message
+ expected int // expected number of output items
+ }{
+ {
+ name: "simple user message",
+ msg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Hello"},
+ },
+ },
+ expected: 1,
+ },
+ {
+ name: "assistant message with text",
+ msg: llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Hi there"},
+ },
+ },
+ expected: 1,
+ },
+ {
+ name: "message with tool use",
+ msg: llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "call_123",
+ ToolName: "get_weather",
+ ToolInput: json.RawMessage(`{"location":"SF"}`),
+ },
+ },
+ },
+ expected: 1,
+ },
+ {
+ name: "message with tool result",
+ msg: llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "call_123",
+ ToolResult: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "72 degrees"},
+ },
+ },
+ },
+ },
+ expected: 1,
+ },
+ {
+ name: "message with text and tool use",
+ msg: llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Let me check"},
+ {
+ Type: llm.ContentTypeToolUse,
+ ID: "call_123",
+ ToolName: "get_weather",
+ ToolInput: json.RawMessage(`{"location":"SF"}`),
+ },
+ },
+ },
+ expected: 2, // one message item, one function_call item
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ items := fromLLMMessageResponses(tt.msg)
+ if len(items) != tt.expected {
+ t.Errorf("expected %d items, got %d", tt.expected, len(items))
+ }
+
+ // Verify structure based on content type
+ for _, item := range items {
+ switch item.Type {
+ case "message":
+ if item.Role == "" {
+ t.Error("message item missing role")
+ }
+ if len(item.Content) == 0 {
+ t.Error("message item has no content")
+ }
+ case "function_call":
+ if item.CallID == "" {
+ t.Error("function_call item missing call_id")
+ }
+ if item.Name == "" {
+ t.Error("function_call item missing name")
+ }
+ case "function_call_output":
+ if item.CallID == "" {
+ t.Error("function_call_output item missing call_id")
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestFromLLMToolResponses(t *testing.T) {
+ tool := &llm.Tool{
+ Name: "test_tool",
+ Description: "A test tool",
+ InputSchema: llm.MustSchema(`{
+ "type": "object",
+ "properties": {
+ "param": {"type": "string"}
+ }
+ }`),
+ }
+
+ rtool := fromLLMToolResponses(tool)
+
+ if rtool.Type != "function" {
+ t.Errorf("expected type 'function', got %s", rtool.Type)
+ }
+ if rtool.Name != "test_tool" {
+ t.Errorf("expected name 'test_tool', got %s", rtool.Name)
+ }
+ if rtool.Description != "A test tool" {
+ t.Errorf("expected description 'A test tool', got %s", rtool.Description)
+ }
+ if len(rtool.Parameters) == 0 {
+ t.Error("expected parameters to be set")
+ }
+}
+
+func TestFromLLMSystemResponses(t *testing.T) {
+ tests := []struct {
+ name string
+ system []llm.SystemContent
+ expected int
+ }{
+ {
+ name: "empty system",
+ system: []llm.SystemContent{},
+ expected: 0,
+ },
+ {
+ name: "single system message",
+ system: []llm.SystemContent{
+ {Text: "You are a helpful assistant"},
+ },
+ expected: 1,
+ },
+ {
+ name: "multiple system messages",
+ system: []llm.SystemContent{
+ {Text: "You are a helpful assistant"},
+ {Text: "Be concise"},
+ },
+ expected: 1, // should be combined into one message
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ items := fromLLMSystemResponses(tt.system)
+ if len(items) != tt.expected {
+ t.Errorf("expected %d items, got %d", len(items), tt.expected)
+ }
+ })
+ }
+}
+
+func TestToLLMResponseFromResponses(t *testing.T) {
+ svc := &ResponsesService{}
+
+ tests := []struct {
+ name string
+ resp *responsesResponse
+ expectedReason llm.StopReason
+ contentCount int
+ }{
+ {
+ name: "simple text response",
+ resp: &responsesResponse{
+ ID: "resp_123",
+ Model: "gpt-5.1-codex",
+ Output: []responsesOutputItem{
+ {
+ Type: "message",
+ Role: "assistant",
+ Content: []responsesContent{
+ {Type: "output_text", Text: "Hello!"},
+ },
+ },
+ },
+ },
+ expectedReason: llm.StopReasonStopSequence,
+ contentCount: 1,
+ },
+ {
+ name: "response with function call",
+ resp: &responsesResponse{
+ ID: "resp_123",
+ Model: "gpt-5.1-codex",
+ Output: []responsesOutputItem{
+ {
+ Type: "function_call",
+ CallID: "call_123",
+ Name: "get_weather",
+ Arguments: `{"location":"SF"}`,
+ },
+ },
+ },
+ expectedReason: llm.StopReasonToolUse,
+ contentCount: 1,
+ },
+ {
+ name: "response with reasoning and message",
+ resp: &responsesResponse{
+ ID: "resp_123",
+ Model: "gpt-5.1-codex",
+ Output: []responsesOutputItem{
+ {
+ Type: "reasoning",
+ Summary: []string{"Let me think", "about this"},
+ },
+ {
+ Type: "message",
+ Role: "assistant",
+ Content: []responsesContent{
+ {Type: "output_text", Text: "Here's the answer"},
+ },
+ },
+ },
+ },
+ expectedReason: llm.StopReasonStopSequence,
+ contentCount: 2, // reasoning + text
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ llmResp := svc.toLLMResponseFromResponses(tt.resp, nil)
+
+ if llmResp.ID != tt.resp.ID {
+ t.Errorf("expected ID %s, got %s", tt.resp.ID, llmResp.ID)
+ }
+ if llmResp.Model != tt.resp.Model {
+ t.Errorf("expected model %s, got %s", tt.resp.Model, llmResp.Model)
+ }
+ if llmResp.StopReason != tt.expectedReason {
+ t.Errorf("expected stop reason %v, got %v", tt.expectedReason, llmResp.StopReason)
+ }
+ if len(llmResp.Content) != tt.contentCount {
+ t.Errorf("expected %d content items, got %d", tt.contentCount, len(llmResp.Content))
+ }
+ })
+ }
+}
+
+func TestResponsesServiceTokenContextWindow(t *testing.T) {
+ tests := []struct {
+ model Model
+ expected int
+ }{
+ {model: GPT5Codex, expected: 256000},
+ {model: GPT41, expected: 200000},
+ {model: GPT4o, expected: 128000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.model.UserName, func(t *testing.T) {
+ svc := &ResponsesService{Model: tt.model}
+ got := svc.TokenContextWindow()
+ if got != tt.expected {
+ t.Errorf("expected %d, got %d", tt.expected, got)
+ }
+ })
+ }
+}
+
+func TestResponsesServiceConfigDetails(t *testing.T) {
+ svc := &ResponsesService{
+ Model: GPT5Codex,
+ APIKey: "test-key",
+ }
+
+ details := svc.ConfigDetails()
+
+ if details["model_name"] != "gpt-5.1-codex" {
+ t.Errorf("expected model_name 'gpt-5.1-codex', got %s", details["model_name"])
+ }
+ if details["full_url"] != "https://api.openai.com/v1/responses" {
+ t.Errorf("unexpected full_url: %s", details["full_url"])
+ }
+ if details["has_api_key_set"] != "true" {
+ t.Error("expected has_api_key_set to be true")
+ }
+}
+
+// TestResponsesServiceIntegration is a live test that requires OPENAI_API_KEY
+// Run with: go test -v -run TestResponsesServiceIntegration
+func TestResponsesServiceIntegration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test in short mode")
+ }
+
+ apiKey := os.Getenv(OpenAIAPIKeyEnv)
+ if apiKey == "" {
+ t.Skip("OPENAI_API_KEY not set, skipping integration test")
+ }
+
+ svc := &ResponsesService{
+ APIKey: apiKey,
+ Model: GPT5Codex,
+ }
+
+ ctx := context.Background()
+
+ t.Run("simple request", func(t *testing.T) {
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Say 'hello' and nothing else"},
+ },
+ },
+ },
+ }
+
+ resp, err := svc.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+
+ if resp.ID == "" {
+ t.Error("expected response ID to be set")
+ }
+ if resp.Model != "gpt-5.1-codex" {
+ t.Errorf("expected model gpt-5.1-codex, got %s", resp.Model)
+ }
+ if len(resp.Content) == 0 {
+ t.Error("expected response to have content")
+ }
+ })
+
+ t.Run("request with tools", func(t *testing.T) {
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "What's the weather in Paris?"},
+ },
+ },
+ },
+ Tools: []*llm.Tool{
+ {
+ Name: "get_weather",
+ Description: "Get weather for a location",
+ InputSchema: llm.MustSchema(`{
+ "type": "object",
+ "properties": {
+ "location": {"type": "string"}
+ },
+ "required": ["location"]
+ }`),
+ },
+ },
+ }
+
+ resp, err := svc.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+
+ if resp.StopReason != llm.StopReasonToolUse {
+ t.Errorf("expected tool use stop reason, got %v", resp.StopReason)
+ }
+
+ // Find the tool use content
+ var foundToolUse bool
+ for _, c := range resp.Content {
+ if c.Type == llm.ContentTypeToolUse {
+ foundToolUse = true
+ if c.ToolName != "get_weather" {
+ t.Errorf("expected tool name get_weather, got %s", c.ToolName)
+ }
+ }
+ }
+ if !foundToolUse {
+ t.Error("expected to find tool use in response")
+ }
+ })
+}
@@ -0,0 +1,103 @@
+package oai
+
+import "testing"
+
+func TestRequiresMaxCompletionTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ model Model
+ expected bool
+ }{
+ {
+ name: "GPT-5 requires max_completion_tokens",
+ model: GPT5,
+ expected: true,
+ },
+ {
+ name: "GPT-5 Mini requires max_completion_tokens",
+ model: GPT5Mini,
+ expected: true,
+ },
+ {
+ name: "O3 reasoning model requires max_completion_tokens",
+ model: O3,
+ expected: true,
+ },
+ {
+ name: "O4-mini reasoning model requires max_completion_tokens",
+ model: O4Mini,
+ expected: true,
+ },
+ {
+ name: "GPT-4.1 uses max_tokens",
+ model: GPT41,
+ expected: false,
+ },
+ {
+ name: "GPT-4o uses max_tokens",
+ model: GPT4o,
+ expected: false,
+ },
+ {
+ name: "GPT-4o Mini uses max_tokens",
+ model: GPT4oMini,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.model.requiresMaxCompletionTokens()
+ if result != tt.expected {
+ t.Errorf("requiresMaxCompletionTokens() = %v, expected %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestRequestParameterGeneration(t *testing.T) {
+ // Test that we can generate the correct request structure without making API calls
+ tests := []struct {
+ name string
+ model Model
+ expectMaxTokens bool
+ expectMaxCompletionTokens bool
+ }{
+ {
+ name: "GPT-5 uses max_completion_tokens",
+ model: GPT5,
+ expectMaxTokens: false,
+ expectMaxCompletionTokens: true,
+ },
+ {
+ name: "GPT-5 Mini uses max_completion_tokens",
+ model: GPT5Mini,
+ expectMaxTokens: false,
+ expectMaxCompletionTokens: true,
+ },
+ {
+ name: "GPT-4.1 uses max_tokens",
+ model: GPT41,
+ expectMaxTokens: true,
+ expectMaxCompletionTokens: false,
+ },
+ {
+ name: "O3 uses max_completion_tokens",
+ model: O3,
+ expectMaxTokens: false,
+ expectMaxCompletionTokens: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ usesMaxCompletionTokens := tt.model.requiresMaxCompletionTokens()
+ if tt.expectMaxCompletionTokens && !usesMaxCompletionTokens {
+ t.Errorf("Expected model %s to use max_completion_tokens, but it doesn't", tt.model.UserName)
+ }
+ if tt.expectMaxTokens && usesMaxCompletionTokens {
+ t.Errorf("Expected model %s to use max_tokens, but it uses max_completion_tokens", tt.model.UserName)
+ }
+ })
+ }
+}
@@ -0,0 +1,37 @@
+package llm
+
+import (
+ "testing"
+)
+
+func TestToolResultArray(t *testing.T) {
+ // Test a tool result with multiple content items
+ textContent := Content{
+ Type: ContentTypeText,
+ Text: "15 degrees",
+ }
+
+ imageContent := Content{
+ Type: ContentTypeText, // In the future, this could be ContentTypeImage
+ Text: "",
+ MediaType: "image/jpeg",
+ Data: "/9j/4AAQSkZJRg...", // Base64 encoded image sample
+ }
+
+ toolResult := Content{
+ ToolResult: []Content{textContent, imageContent},
+ }
+
+ // Check the structure
+ if len(toolResult.ToolResult) != 2 {
+ t.Errorf("Expected 2 content items in ToolResult, got %d", len(toolResult.ToolResult))
+ }
+
+ if toolResult.ToolResult[0].Text != "15 degrees" {
+ t.Errorf("Expected first item text to be '15 degrees', got '%s'", toolResult.ToolResult[0].Text)
+ }
+
+ if toolResult.ToolResult[1].MediaType != "image/jpeg" {
+ t.Errorf("Expected second item media type to be 'image/jpeg', got '%s'", toolResult.ToolResult[1].MediaType)
+ }
+}
@@ -0,0 +1,100 @@
+package llm
+
+import "testing"
+
+func TestUsageTotalInputTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ usage Usage
+ want uint64
+ }{
+ {
+ name: "all token types",
+ usage: Usage{
+ InputTokens: 100,
+ CacheCreationInputTokens: 50,
+ CacheReadInputTokens: 200,
+ OutputTokens: 30,
+ },
+ want: 350, // 100 + 50 + 200
+ },
+ {
+ name: "only input tokens",
+ usage: Usage{
+ InputTokens: 150,
+ OutputTokens: 50,
+ },
+ want: 150,
+ },
+ {
+ name: "heavy caching",
+ usage: Usage{
+ InputTokens: 10,
+ CacheCreationInputTokens: 0,
+ CacheReadInputTokens: 5000,
+ OutputTokens: 100,
+ },
+ want: 5010, // 10 + 0 + 5000
+ },
+ {
+ name: "zero",
+ usage: Usage{},
+ want: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.usage.TotalInputTokens()
+ if got != tt.want {
+ t.Errorf("TotalInputTokens() = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestUsageContextWindowUsed(t *testing.T) {
+ tests := []struct {
+ name string
+ usage Usage
+ want uint64
+ }{
+ {
+ name: "all token types",
+ usage: Usage{
+ InputTokens: 100,
+ CacheCreationInputTokens: 50,
+ CacheReadInputTokens: 200,
+ OutputTokens: 30,
+ },
+ want: 380, // 100 + 50 + 200 + 30
+ },
+ {
+ name: "only input and output",
+ usage: Usage{
+ InputTokens: 150,
+ OutputTokens: 50,
+ },
+ want: 200,
+ },
+ {
+ name: "heavy caching with output",
+ usage: Usage{
+ InputTokens: 10,
+ CacheCreationInputTokens: 0,
+ CacheReadInputTokens: 5000,
+ OutputTokens: 100,
+ },
+ want: 5110, // 10 + 0 + 5000 + 100
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.usage.ContextWindowUsed()
+ if got != tt.want {
+ t.Errorf("ContextWindowUsed() = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
@@ -0,0 +1,71 @@
+# Loop Package
+
+The `loop` package provides the core agentic conversation loop for Shelley,
+handling LLM interactions, tool execution, and message recording.
+
+## Features
+
+- **LLM Integration**: Works with any LLM service implementing the `llm.Service` interface
+- **Predictable Testing**: Includes a `PredictableService` for deterministic testing
+- **Tool Execution**: Automatically executes tools called by the LLM
+- **Message Recording**: Records all conversation messages via a configurable function
+- **Usage Tracking**: Tracks token usage and costs across all LLM calls
+- **Context Cancellation**: Gracefully handles context cancellation
+- **Thread Safety**: All methods are safe for concurrent use
+
+## Basic Usage
+
+```go
+// Create tools (using claudetool package or custom tools)
+tools := []*llm.Tool{bashTool, patchTool, thinkTool}
+
+// Define message recording function (typically saves to the database)
+recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return messageService.Create(ctx, db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: getMessageType(message.Role),
+ LLMData: message,
+ UsageData: usage,
+ })
+}
+
+// Create loop with explicit LLM configuration
+agentLoop := loop.NewLoop(loop.Config{
+ LLM: &ant.Service{APIKey: apiKey},
+ History: history, // existing conversation history
+ Tools: tools,
+ RecordMessage: recordMessage,
+ Logger: logger,
+ System: systemPrompt, // []llm.SystemContent
+})
+
+// Queue user messages for the current turn
+agentLoop.QueueUserMessage(llm.UserStringMessage("Hello, please help me with something"))
+
+// Run the conversation turn
+ctx := context.Background()
+if err := agentLoop.ProcessOneTurn(ctx); err != nil {
+ log.Fatalf("conversation failed: %v", err)
+}
+```
+
+## Testing with PredictableService
+
+The `PredictableService` records requests and returns deterministic responses that are convenient for tests:
+
+```go
+service := loop.NewPredictableService()
+
+testLoop := loop.NewLoop(loop.Config{
+ LLM: service,
+ RecordMessage: func(context.Context, llm.Message, llm.Usage) error { return nil },
+})
+
+testLoop.QueueUserMessage(llm.UserStringMessage("hello"))
+if err := testLoop.ProcessOneTurn(context.Background()); err != nil {
+ t.Fatalf("loop failed: %v", err)
+}
+
+last := service.GetLastRequest()
+require.NotNil(t, last)
+```
@@ -0,0 +1,69 @@
+package loop
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/ant"
+)
+
+// TestLoopWithClaude tests the loop with actual Claude API if key is available
+func TestLoopWithClaude(t *testing.T) {
+ apiKey := os.Getenv("ANTHROPIC_API_KEY")
+ if apiKey == "" {
+ t.Skip("Skipping Claude integration test - ANTHROPIC_API_KEY not set")
+ }
+
+ // Create a simple conversation with Claude service
+ loop := NewLoop(Config{
+ LLM: &ant.Service{
+ APIKey: apiKey,
+ Model: ant.Claude45Haiku, // Use cheaper model for testing
+ },
+ History: []llm.Message{},
+ Tools: []*llm.Tool{},
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ // In a real app, this would save to database
+ t.Logf("Recorded %s message: %s", message.Role, message.Content[0].Text)
+ return nil
+ },
+ })
+
+ // Queue a simple user message
+ loop.QueueUserMessage(llm.UserStringMessage("Hello! Please respond with just 'Hi there!' and nothing else."))
+
+ // Run with a reasonable timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ err := loop.Go(ctx)
+ if err != context.DeadlineExceeded {
+ t.Errorf("expected context deadline exceeded, got %v", err)
+ }
+
+ // Check that usage was tracked
+ usage := loop.GetUsage()
+ if usage.IsZero() {
+ t.Error("expected non-zero usage from Claude API")
+ }
+
+ t.Logf("Claude API usage: %s", usage.String())
+
+ // Check conversation history
+ history := loop.GetHistory()
+ if len(history) < 2 {
+ t.Errorf("expected at least 2 messages in history, got %d", len(history))
+ }
+
+ // First should be user message, second should be assistant
+ if history[0].Role != llm.MessageRoleUser {
+ t.Errorf("first message should be user, got %v", history[0].Role)
+ }
+
+ if len(history) > 1 && history[1].Role != llm.MessageRoleAssistant {
+ t.Errorf("second message should be assistant, got %v", history[1].Role)
+ }
+}
@@ -0,0 +1,80 @@
+package loop_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+func ExampleLoop() {
+ // Create a simple tool
+ testTool := &llm.Tool{
+ Name: "greet",
+ Description: "Greets the user with a friendly message",
+ InputSchema: llm.MustSchema(`{"type": "object", "properties": {"name": {"type": "string"}}}`),
+ Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(input, &req); err != nil {
+ return llm.ErrorToolOut(err)
+ }
+ return llm.ToolOut{
+ LLMContent: llm.TextContent(fmt.Sprintf("Hello, %s! Nice to meet you.", req.Name)),
+ }
+ },
+ }
+
+ // Message recording function (in real usage, this would save to database)
+ recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ roleStr := "user"
+ if message.Role == llm.MessageRoleAssistant {
+ roleStr = "assistant"
+ }
+ fmt.Printf("Recorded %s message with %d content items\n", roleStr, len(message.Content))
+ return nil
+ }
+
+ // Create a loop with initial history
+ initialHistory := []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Hello, I'm Alice"},
+ },
+ },
+ }
+
+ // Set up a predictable service for this example
+ service := loop.NewPredictableService()
+ myLoop := loop.NewLoop(loop.Config{
+ LLM: service,
+ History: initialHistory,
+ Tools: []*llm.Tool{testTool},
+ RecordMessage: recordMessage,
+ })
+
+ // Queue a user message that triggers a simple response
+ myLoop.QueueUserMessage(llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ })
+
+ // Run the loop for a short time
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ myLoop.Go(ctx)
+
+ // Check usage
+ usage := myLoop.GetUsage()
+ fmt.Printf("Total usage: %s\n", usage.String())
+
+ // Output:
+ // Recorded assistant message with 1 content items
+ // Total usage: in: 31, out: 3
+}
@@ -0,0 +1,132 @@
+package loop
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/llm"
+)
+
+func TestLoopWithClaudeTools(t *testing.T) {
+ var recordedMessages []llm.Message
+
+ recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ recordedMessages = append(recordedMessages, message)
+ return nil
+ }
+
+ // Use some actual claudetools
+ tools := []*llm.Tool{
+ // TODO: Add actual tools when needed
+ }
+
+ service := NewPredictableService()
+
+ // Create loop with the configured service
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: tools,
+ RecordMessage: recordFunc,
+ })
+
+ // Queue a user message that will trigger a specific predictable response
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ }
+ loop.QueueUserMessage(userMessage)
+
+ // Run the loop with a short timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ err := loop.Go(ctx)
+ if err != context.DeadlineExceeded {
+ t.Errorf("expected context deadline exceeded, got %v", err)
+ }
+
+ // Verify that messages were recorded
+ // Note: User messages are recorded by ConversationManager, not by Loop,
+ // so we only expect assistant messages to be recorded here
+ if len(recordedMessages) < 1 {
+ t.Errorf("expected at least 1 recorded message (assistant), got %d", len(recordedMessages))
+ }
+
+ // Check that usage was accumulated
+ usage := loop.GetUsage()
+ if usage.IsZero() {
+ t.Error("expected non-zero usage")
+ }
+
+ // Verify conversation history includes user and assistant messages
+ history := loop.GetHistory()
+ if len(history) < 2 {
+ t.Errorf("expected at least 2 history messages, got %d", len(history))
+ }
+
+ // Check for expected response
+ found := false
+ for _, msg := range history {
+ if msg.Role == llm.MessageRoleAssistant {
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeText && content.Text == "Well, hi there!" {
+ found = true
+ break
+ }
+ }
+ }
+ }
+ if !found {
+ t.Error("expected to find 'Well, hi there!' response")
+ }
+}
+
+func TestLoopContextCancellation(t *testing.T) {
+ service := NewPredictableService()
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: []*llm.Tool{},
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ },
+ })
+
+ // Cancel context immediately
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := loop.Go(ctx)
+ if err != context.Canceled {
+ t.Errorf("expected context canceled, got %v", err)
+ }
+}
+
+func TestLoopSystemMessages(t *testing.T) {
+ // Set system messages
+ system := []llm.SystemContent{
+ {Text: "You are a helpful assistant.", Type: "text"},
+ }
+
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ Tools: []*llm.Tool{},
+ System: system,
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ },
+ })
+
+ // The system messages are stored and would be passed to LLM
+ loop.mu.Lock()
+ if len(loop.system) != 1 {
+ t.Errorf("expected 1 system message, got %d", len(loop.system))
+ }
+ if loop.system[0].Text != "You are a helpful assistant." {
+ t.Errorf("unexpected system message text: %s", loop.system[0].Text)
+ }
+ loop.mu.Unlock()
+}
@@ -0,0 +1,509 @@
+package loop
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/llm"
+)
+
+// MessageRecordFunc is called to record new messages to persistent storage
+type MessageRecordFunc func(ctx context.Context, message llm.Message, usage llm.Usage) error
+
+// Config contains all configuration needed to create a Loop
+type Config struct {
+ LLM llm.Service
+ History []llm.Message
+ Tools []*llm.Tool
+ RecordMessage MessageRecordFunc
+ Logger *slog.Logger
+ System []llm.SystemContent
+ WorkingDir string // working directory for tools
+}
+
+// Loop manages a conversation turn with an LLM including tool execution and message recording.
+// Notably, when the turn ends, the "Loop" is over. TODO: maybe rename to Turn?
+type Loop struct {
+ llm llm.Service
+ tools []*llm.Tool
+ recordMessage MessageRecordFunc
+ history []llm.Message
+ messageQueue []llm.Message
+ totalUsage llm.Usage
+ mu sync.Mutex
+ logger *slog.Logger
+ system []llm.SystemContent
+ workingDir string
+}
+
+// NewLoop creates a new Loop instance with the provided configuration
+func NewLoop(config Config) *Loop {
+ logger := config.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ return &Loop{
+ llm: config.LLM,
+ history: config.History,
+ tools: config.Tools,
+ recordMessage: config.RecordMessage,
+ messageQueue: make([]llm.Message, 0),
+ logger: logger,
+ system: config.System,
+ workingDir: config.WorkingDir,
+ }
+}
+
+// QueueUserMessage adds a user message to the queue to be processed
+func (l *Loop) QueueUserMessage(message llm.Message) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.messageQueue = append(l.messageQueue, message)
+ l.logger.Debug("queued user message", "content_count", len(message.Content))
+}
+
+// GetUsage returns the total usage accumulated by this loop
+func (l *Loop) GetUsage() llm.Usage {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.totalUsage
+}
+
+// GetHistory returns a copy of the current conversation history
+func (l *Loop) GetHistory() []llm.Message {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ // Deep copy the messages to prevent modifications
+ historyCopy := make([]llm.Message, len(l.history))
+ for i, msg := range l.history {
+ // Copy the message
+ historyCopy[i] = llm.Message{
+ Role: msg.Role,
+ ToolUse: msg.ToolUse, // This is a pointer, but we won't modify it in tests
+ Content: make([]llm.Content, len(msg.Content)),
+ }
+ // Copy content slice
+ copy(historyCopy[i].Content, msg.Content)
+ }
+ return historyCopy
+}
+
+// Go runs the conversation loop until the context is canceled
+func (l *Loop) Go(ctx context.Context) error {
+ if l.llm == nil {
+ return fmt.Errorf("no LLM service configured")
+ }
+
+ l.logger.Info("starting conversation loop", "tools", len(l.tools))
+
+ for {
+ select {
+ case <-ctx.Done():
+ l.logger.Info("conversation loop canceled")
+ return ctx.Err()
+ default:
+ }
+
+ // Process any queued messages
+ l.mu.Lock()
+ hasQueuedMessages := len(l.messageQueue) > 0
+ if hasQueuedMessages {
+ // Add queued messages to history (they are already recorded to DB by ConversationManager)
+ for _, msg := range l.messageQueue {
+ l.history = append(l.history, msg)
+ }
+ l.messageQueue = l.messageQueue[:0] // Clear queue
+ }
+ l.mu.Unlock()
+
+ if hasQueuedMessages {
+ // Send request to LLM
+ l.logger.Debug("processing queued messages", "count", 1)
+ if err := l.processLLMRequest(ctx); err != nil {
+ l.logger.Error("failed to process LLM request", "error", err)
+ time.Sleep(time.Second) // Wait before retrying
+ continue
+ }
+ l.logger.Debug("finished processing queued messages")
+ } else {
+ // No queued messages, wait a bit
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ // Continue loop
+ }
+ }
+ }
+}
+
+// ProcessOneTurn processes queued messages through one complete turn (user message + assistant response)
+// It stops after the assistant responds, regardless of whether tools were called
+func (l *Loop) ProcessOneTurn(ctx context.Context) error {
+ if l.llm == nil {
+ return fmt.Errorf("no LLM service configured")
+ }
+
+ // Process any queued messages first
+ l.mu.Lock()
+ if len(l.messageQueue) > 0 {
+ // Add queued messages to history (they are already recorded to DB by ConversationManager)
+ for _, msg := range l.messageQueue {
+ l.history = append(l.history, msg)
+ }
+ l.messageQueue = nil
+ }
+ l.mu.Unlock()
+
+ // Process one LLM request and response
+ return l.processLLMRequest(ctx)
+}
+
+// processLLMRequest sends a request to the LLM and handles the response
+func (l *Loop) processLLMRequest(ctx context.Context) error {
+ l.mu.Lock()
+ messages := append([]llm.Message(nil), l.history...)
+ tools := l.tools
+ system := l.system
+ llmService := l.llm
+ l.mu.Unlock()
+
+ // Enable prompt caching: set cache flag on last tool and last user message content
+ // See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
+ if len(tools) > 0 {
+ // Make a copy of tools to avoid modifying the shared slice
+ tools = append([]*llm.Tool(nil), tools...)
+ // Copy the last tool and enable caching
+ lastTool := *tools[len(tools)-1]
+ lastTool.Cache = true
+ tools[len(tools)-1] = &lastTool
+ }
+
+ // Set cache flag on the last content block of the last user message
+ if len(messages) > 0 {
+ for i := len(messages) - 1; i >= 0; i-- {
+ if messages[i].Role == llm.MessageRoleUser && len(messages[i].Content) > 0 {
+ // Deep copy the message to avoid modifying the shared history
+ msg := messages[i]
+ msg.Content = append([]llm.Content(nil), msg.Content...)
+ msg.Content[len(msg.Content)-1].Cache = true
+ messages[i] = msg
+ break
+ }
+ }
+ }
+
+ req := &llm.Request{
+ Messages: messages,
+ Tools: tools,
+ System: system,
+ }
+
+ // Insert missing tool results if the previous message had tool_use blocks
+ // without corresponding tool_result blocks. This can happen when a request
+ // is cancelled or fails after the LLM responds but before tools execute.
+ l.insertMissingToolResults(req)
+
+ systemLen := 0
+ for _, sys := range system {
+ systemLen += len(sys.Text)
+ }
+ l.logger.Debug("sending LLM request", "message_count", len(messages), "tool_count", len(tools), "system_items", len(system), "system_length", systemLen)
+
+ // Add a timeout for the LLM request to prevent indefinite hangs
+ llmCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+ defer cancel()
+
+ resp, err := llmService.Do(llmCtx, req)
+ if err != nil {
+ // Record the error as a message so it can be displayed in the UI
+ errorMessage := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeText,
+ Text: fmt.Sprintf("LLM request failed: %v", err),
+ },
+ },
+ }
+ if recordErr := l.recordMessage(ctx, errorMessage, llm.Usage{}); recordErr != nil {
+ l.logger.Error("failed to record error message", "error", recordErr)
+ }
+ return fmt.Errorf("LLM request failed: %w", err)
+ }
+
+ l.logger.Debug("received LLM response", "content_count", len(resp.Content), "stop_reason", resp.StopReason.String(), "usage", resp.Usage.String())
+
+ // Update total usage
+ l.mu.Lock()
+ l.totalUsage.Add(resp.Usage)
+ l.mu.Unlock()
+
+ // Convert response to message and add to history
+ assistantMessage := resp.ToMessage()
+ l.mu.Lock()
+ l.history = append(l.history, assistantMessage)
+ l.mu.Unlock()
+
+ // Record assistant message with model and timing metadata
+ usageWithMeta := resp.Usage
+ usageWithMeta.Model = resp.Model
+ usageWithMeta.StartTime = resp.StartTime
+ usageWithMeta.EndTime = resp.EndTime
+ if err := l.recordMessage(ctx, assistantMessage, usageWithMeta); err != nil {
+ l.logger.Error("failed to record assistant message", "error", err)
+ }
+
+ // Handle tool calls if any
+ if resp.StopReason == llm.StopReasonToolUse {
+ l.logger.Debug("handling tool calls", "content_count", len(resp.Content))
+ return l.handleToolCalls(ctx, resp.Content)
+ }
+
+ return nil
+}
+
+// handleToolCalls processes tool calls from the LLM response
+func (l *Loop) handleToolCalls(ctx context.Context, content []llm.Content) error {
+ var toolResults []llm.Content
+
+ for _, c := range content {
+ if c.Type != llm.ContentTypeToolUse {
+ continue
+ }
+
+ l.logger.Debug("executing tool", "name", c.ToolName, "id", c.ID)
+
+ // Find the tool
+ var tool *llm.Tool
+ for _, t := range l.tools {
+ if t.Name == c.ToolName {
+ tool = t
+ break
+ }
+ }
+
+ if tool == nil {
+ l.logger.Error("tool not found", "name", c.ToolName)
+ toolResults = append(toolResults, llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: c.ID,
+ ToolError: true,
+ ToolResult: []llm.Content{
+ {Type: llm.ContentTypeText, Text: fmt.Sprintf("Tool '%s' not found", c.ToolName)},
+ },
+ })
+ continue
+ }
+
+ // Execute the tool with working directory set in context
+ toolCtx := ctx
+ if l.workingDir != "" {
+ toolCtx = claudetool.WithWorkingDir(ctx, l.workingDir)
+ }
+ startTime := time.Now()
+ result := tool.Run(toolCtx, c.ToolInput)
+ endTime := time.Now()
+
+ var toolResultContent []llm.Content
+ if result.Error != nil {
+ l.logger.Error("tool execution failed", "name", c.ToolName, "error", result.Error)
+ toolResultContent = []llm.Content{
+ {Type: llm.ContentTypeText, Text: result.Error.Error()},
+ }
+ } else {
+ toolResultContent = result.LLMContent
+ l.logger.Debug("tool executed successfully", "name", c.ToolName, "duration", endTime.Sub(startTime))
+ }
+
+ toolResults = append(toolResults, llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: c.ID,
+ ToolError: result.Error != nil,
+ ToolResult: toolResultContent,
+ ToolUseStartTime: &startTime,
+ ToolUseEndTime: &endTime,
+ Display: result.Display,
+ })
+ }
+
+ if len(toolResults) > 0 {
+ // Add tool results to history as a user message
+ toolMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: toolResults,
+ }
+
+ l.mu.Lock()
+ l.history = append(l.history, toolMessage)
+ l.mu.Unlock()
+
+ // Record tool result message
+ if err := l.recordMessage(ctx, toolMessage, llm.Usage{}); err != nil {
+ l.logger.Error("failed to record tool result message", "error", err)
+ }
+
+ // Process another LLM request with the tool results
+ return l.processLLMRequest(ctx)
+ }
+
+ return nil
+}
+
+// insertMissingToolResults fixes tool_result issues in the conversation history:
+// 1. Adds error results for tool_uses that were requested but not included in the next message.
+// This can happen when a request is cancelled or fails after the LLM responds with tool_use
+// blocks but before the tools execute.
+// 2. Removes orphan tool_results that reference tool_use IDs not present in the immediately
+// preceding assistant message. This can happen when a tool execution completes after
+// CancelConversation has already written cancellation messages.
+//
+// This prevents API errors like:
+// - "tool_use ids were found without tool_result blocks"
+// - "unexpected tool_use_id found in tool_result blocks ... Each tool_result block must have
+// a corresponding tool_use block in the previous message"
+//
+// Mutates the request's Messages slice.
+func (l *Loop) insertMissingToolResults(req *llm.Request) {
+ if len(req.Messages) < 1 {
+ return
+ }
+
+ // Scan through all messages looking for assistant messages with tool_use
+ // that are not immediately followed by a user message with corresponding tool_results.
+ // We may need to insert synthetic user messages with tool_results or filter orphans.
+ var newMessages []llm.Message
+ totalInserted := 0
+ totalRemoved := 0
+
+ // Track the tool_use IDs from the most recent assistant message
+ var prevAssistantToolUseIDs map[string]bool
+
+ for i := 0; i < len(req.Messages); i++ {
+ msg := req.Messages[i]
+
+ if msg.Role == llm.MessageRoleAssistant {
+ // Track all tool_use IDs in this assistant message
+ prevAssistantToolUseIDs = make(map[string]bool)
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolUse {
+ prevAssistantToolUseIDs[c.ID] = true
+ }
+ }
+ newMessages = append(newMessages, msg)
+
+ // Check if next message needs synthetic tool_results
+ var toolUseContents []llm.Content
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolUse {
+ toolUseContents = append(toolUseContents, c)
+ }
+ }
+
+ if len(toolUseContents) == 0 {
+ continue
+ }
+
+ // Check if next message is a user message with corresponding tool_results
+ var nextMsg *llm.Message
+ if i+1 < len(req.Messages) {
+ nextMsg = &req.Messages[i+1]
+ }
+
+ if nextMsg == nil || nextMsg.Role != llm.MessageRoleUser {
+ // Next message is not a user message (or there is no next message).
+ // Insert a synthetic user message with tool_results for all tool_uses.
+ var toolResultContent []llm.Content
+ for _, tu := range toolUseContents {
+ toolResultContent = append(toolResultContent, llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: tu.ID,
+ ToolError: true,
+ ToolResult: []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: "not executed; retry possible",
+ }},
+ })
+ }
+ syntheticMsg := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: toolResultContent,
+ }
+ newMessages = append(newMessages, syntheticMsg)
+ totalInserted += len(toolResultContent)
+ }
+ } else if msg.Role == llm.MessageRoleUser {
+ // Filter out orphan tool_results and add missing ones
+ var filteredContent []llm.Content
+ existingResultIDs := make(map[string]bool)
+
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult {
+ // Only keep tool_results that match a tool_use in the previous assistant message
+ if prevAssistantToolUseIDs != nil && prevAssistantToolUseIDs[c.ToolUseID] {
+ filteredContent = append(filteredContent, c)
+ existingResultIDs[c.ToolUseID] = true
+ } else {
+ // Orphan tool_result - skip it
+ totalRemoved++
+ l.logger.Debug("removing orphan tool_result", "tool_use_id", c.ToolUseID)
+ }
+ } else {
+ // Keep non-tool_result content
+ filteredContent = append(filteredContent, c)
+ }
+ }
+
+ // Check if we need to add missing tool_results for this user message
+ if prevAssistantToolUseIDs != nil {
+ var prefix []llm.Content
+ for toolUseID := range prevAssistantToolUseIDs {
+ if !existingResultIDs[toolUseID] {
+ prefix = append(prefix, llm.Content{
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: toolUseID,
+ ToolError: true,
+ ToolResult: []llm.Content{{
+ Type: llm.ContentTypeText,
+ Text: "not executed; retry possible",
+ }},
+ })
+ totalInserted++
+ }
+ }
+ if len(prefix) > 0 {
+ filteredContent = append(prefix, filteredContent...)
+ }
+ }
+
+ // Only add the message if it has content
+ if len(filteredContent) > 0 {
+ msg.Content = filteredContent
+ newMessages = append(newMessages, msg)
+ } else {
+ // Message is now empty after filtering - skip it entirely
+ l.logger.Debug("removing empty user message after filtering orphan tool_results")
+ }
+
+ // Reset for next iteration - user message "consumes" the previous tool_uses
+ prevAssistantToolUseIDs = nil
+ } else {
+ newMessages = append(newMessages, msg)
+ }
+ }
+
+ if totalInserted > 0 || totalRemoved > 0 {
+ req.Messages = newMessages
+ if totalInserted > 0 {
+ l.logger.Debug("inserted missing tool results", "count", totalInserted)
+ }
+ if totalRemoved > 0 {
+ l.logger.Debug("removed orphan tool results", "count", totalRemoved)
+ }
+ }
+}
@@ -0,0 +1,843 @@
+package loop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/llm"
+)
+
+func TestNewLoop(t *testing.T) {
+ history := []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+ }
+ tools := []*llm.Tool{}
+ recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ }
+
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: history,
+ Tools: tools,
+ RecordMessage: recordFunc,
+ })
+ if loop == nil {
+ t.Fatal("NewLoop returned nil")
+ }
+
+ if len(loop.history) != 1 {
+ t.Errorf("expected history length 1, got %d", len(loop.history))
+ }
+
+ if len(loop.messageQueue) != 0 {
+ t.Errorf("expected empty message queue, got %d", len(loop.messageQueue))
+ }
+}
+
+func TestQueueUserMessage(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ Tools: []*llm.Tool{},
+ })
+
+ message := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Test message"}},
+ }
+
+ loop.QueueUserMessage(message)
+
+ loop.mu.Lock()
+ queueLen := len(loop.messageQueue)
+ loop.mu.Unlock()
+
+ if queueLen != 1 {
+ t.Errorf("expected message queue length 1, got %d", queueLen)
+ }
+}
+
+func TestPredictableService(t *testing.T) {
+ service := NewPredictableService()
+
+ // Test simple hello response
+ ctx := context.Background()
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+ },
+ }
+
+ resp, err := service.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("predictable service Do failed: %v", err)
+ }
+
+ if resp.Role != llm.MessageRoleAssistant {
+ t.Errorf("expected assistant role, got %v", resp.Role)
+ }
+
+ if len(resp.Content) == 0 {
+ t.Error("expected non-empty content")
+ }
+
+ if resp.Content[0].Type != llm.ContentTypeText {
+ t.Errorf("expected text content, got %v", resp.Content[0].Type)
+ }
+
+ if resp.Content[0].Text != "Well, hi there!" {
+ t.Errorf("unexpected response text: %s", resp.Content[0].Text)
+ }
+}
+
+func TestPredictableServiceEcho(t *testing.T) {
+ service := NewPredictableService()
+
+ ctx := context.Background()
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "echo: foo"}}},
+ },
+ }
+
+ resp, err := service.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("echo test failed: %v", err)
+ }
+
+ if resp.Content[0].Text != "foo" {
+ t.Errorf("expected 'foo', got '%s'", resp.Content[0].Text)
+ }
+
+ // Test another echo
+ req.Messages[0].Content[0].Text = "echo: hello world"
+ resp, err = service.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("echo hello world test failed: %v", err)
+ }
+
+ if resp.Content[0].Text != "hello world" {
+ t.Errorf("expected 'hello world', got '%s'", resp.Content[0].Text)
+ }
+}
+
+func TestPredictableServiceBashTool(t *testing.T) {
+ service := NewPredictableService()
+
+ ctx := context.Background()
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "bash: ls -la"}}},
+ },
+ }
+
+ resp, err := service.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("bash tool test failed: %v", err)
+ }
+
+ if resp.StopReason != llm.StopReasonToolUse {
+ t.Errorf("expected tool use stop reason, got %v", resp.StopReason)
+ }
+
+ if len(resp.Content) != 2 {
+ t.Errorf("expected 2 content items (text + tool_use), got %d", len(resp.Content))
+ }
+
+ // Find the tool use content
+ var toolUseContent *llm.Content
+ for _, content := range resp.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ toolUseContent = &content
+ break
+ }
+ }
+
+ if toolUseContent == nil {
+ t.Fatal("no tool use content found")
+ }
+
+ if toolUseContent.ToolName != "bash" {
+ t.Errorf("expected tool name 'bash', got '%s'", toolUseContent.ToolName)
+ }
+
+ // Check tool input contains the command
+ var toolInput map[string]interface{}
+ if err := json.Unmarshal(toolUseContent.ToolInput, &toolInput); err != nil {
+ t.Fatalf("failed to parse tool input: %v", err)
+ }
+
+ if toolInput["command"] != "ls -la" {
+ t.Errorf("expected command 'ls -la', got '%v'", toolInput["command"])
+ }
+}
+
+func TestPredictableServiceDefaultResponse(t *testing.T) {
+ service := NewPredictableService()
+
+ ctx := context.Background()
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "some unknown input"}}},
+ },
+ }
+
+ resp, err := service.Do(ctx, req)
+ if err != nil {
+ t.Fatalf("default response test failed: %v", err)
+ }
+
+ if resp.Content[0].Text != "edit predictable.go to add a response for that one..." {
+ t.Errorf("unexpected default response: %s", resp.Content[0].Text)
+ }
+}
+
+func TestPredictableServiceDelay(t *testing.T) {
+ service := NewPredictableService()
+
+ ctx := context.Background()
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "delay: 0.1"}}},
+ },
+ }
+
+ start := time.Now()
+ resp, err := service.Do(ctx, req)
+ elapsed := time.Since(start)
+
+ if err != nil {
+ t.Fatalf("delay test failed: %v", err)
+ }
+
+ if elapsed < 100*time.Millisecond {
+ t.Errorf("expected delay of at least 100ms, got %v", elapsed)
+ }
+
+ if resp.Content[0].Text != "Delayed for 0.1 seconds" {
+ t.Errorf("unexpected response text: %s", resp.Content[0].Text)
+ }
+}
+
+func TestLoopWithPredictableService(t *testing.T) {
+ var recordedMessages []llm.Message
+ var recordedUsages []llm.Usage
+
+ recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ recordedMessages = append(recordedMessages, message)
+ recordedUsages = append(recordedUsages, usage)
+ return nil
+ }
+
+ service := NewPredictableService()
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: []*llm.Tool{},
+ RecordMessage: recordFunc,
+ })
+
+ // Queue a user message that triggers a known response
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ }
+ loop.QueueUserMessage(userMessage)
+
+ // Run the loop with a short timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ err := loop.Go(ctx)
+ if err != context.DeadlineExceeded {
+ t.Errorf("expected context deadline exceeded, got %v", err)
+ }
+
+ // Check that messages were recorded
+ if len(recordedMessages) < 1 {
+ t.Errorf("expected at least 1 recorded message, got %d", len(recordedMessages))
+ }
+
+ // Check usage tracking
+ usage := loop.GetUsage()
+ if usage.IsZero() {
+ t.Error("expected non-zero usage")
+ }
+}
+
+func TestLoopWithTools(t *testing.T) {
+ var toolCalls []string
+
+ testTool := &llm.Tool{
+ Name: "bash",
+ Description: "A test bash tool",
+ InputSchema: llm.MustSchema(`{"type": "object", "properties": {"command": {"type": "string"}}}`),
+ Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+ toolCalls = append(toolCalls, string(input))
+ return llm.ToolOut{
+ LLMContent: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Command executed successfully"},
+ },
+ }
+ },
+ }
+
+ service := NewPredictableService()
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: []*llm.Tool{testTool},
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ },
+ })
+
+ // Queue a user message that triggers the bash tool
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "bash: echo hello"}},
+ }
+ loop.QueueUserMessage(userMessage)
+
+ // Run the loop with a short timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+
+ err := loop.Go(ctx)
+ if err != context.DeadlineExceeded {
+ t.Errorf("expected context deadline exceeded, got %v", err)
+ }
+
+ // Check that the tool was called
+ if len(toolCalls) != 1 {
+ t.Errorf("expected 1 tool call, got %d", len(toolCalls))
+ }
+
+ if toolCalls[0] != `{"command":"echo hello"}` {
+ t.Errorf("unexpected tool call input: %s", toolCalls[0])
+ }
+}
+
+func TestGetHistory(t *testing.T) {
+ initialHistory := []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+ }
+
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: initialHistory,
+ Tools: []*llm.Tool{},
+ })
+
+ history := loop.GetHistory()
+ if len(history) != 1 {
+ t.Errorf("expected history length 1, got %d", len(history))
+ }
+
+ // Modify returned slice to ensure it's a copy
+ history[0].Content[0].Text = "Modified"
+
+ // Original should be unchanged
+ original := loop.GetHistory()
+ if original[0].Content[0].Text != "Hello" {
+ t.Error("GetHistory should return a copy, not the original slice")
+ }
+}
+
+func TestLoopWithKeywordTool(t *testing.T) {
+ // Test that keyword tool doesn't crash with nil pointer dereference
+ service := NewPredictableService()
+
+ var messages []llm.Message
+ recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ messages = append(messages, message)
+ return nil
+ }
+
+ // Add a mock keyword tool that doesn't actually search
+ tools := []*llm.Tool{
+ {
+ Name: "keyword_search",
+ Description: "Mock keyword search",
+ InputSchema: llm.MustSchema(`{"type": "object", "properties": {"query": {"type": "string"}, "search_terms": {"type": "array", "items": {"type": "string"}}}, "required": ["query", "search_terms"]}`),
+ Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+ // Simple mock implementation
+ return llm.ToolOut{LLMContent: []llm.Content{{Type: llm.ContentTypeText, Text: "mock keyword search result"}}}
+ },
+ },
+ }
+
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: tools,
+ RecordMessage: recordMessage,
+ })
+
+ // Send a user message that will trigger the default response
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Please search for some files"},
+ },
+ }
+
+ loop.QueueUserMessage(userMessage)
+
+ // Process one turn
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := loop.ProcessOneTurn(ctx)
+ if err != nil {
+ t.Fatalf("ProcessOneTurn failed: %v", err)
+ }
+
+ // Verify we got expected messages
+ // Note: User messages are recorded by ConversationManager, not by Loop,
+ // so we only expect the assistant response to be recorded here
+ if len(messages) < 1 {
+ t.Fatalf("Expected at least 1 message (assistant), got %d", len(messages))
+ }
+
+ // Should have assistant response
+ if messages[0].Role != llm.MessageRoleAssistant {
+ t.Errorf("Expected first recorded message to be assistant, got %s", messages[0].Role)
+ }
+}
+
+func TestLoopWithActualKeywordTool(t *testing.T) {
+ // Test that actual keyword tool works with Loop
+ service := NewPredictableService()
+
+ var messages []llm.Message
+ recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ messages = append(messages, message)
+ return nil
+ }
+
+ // Use the actual keyword tool from claudetool package
+ // Note: We need to import it first
+ tools := []*llm.Tool{
+ // Add a simplified keyword tool to avoid file system dependencies in tests
+ {
+ Name: "keyword_search",
+ Description: "Search for files by keyword",
+ InputSchema: llm.MustSchema(`{"type": "object", "properties": {"query": {"type": "string"}, "search_terms": {"type": "array", "items": {"type": "string"}}}, "required": ["query", "search_terms"]}`),
+ Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+ // Simple mock implementation - no context dependencies
+ return llm.ToolOut{LLMContent: []llm.Content{{Type: llm.ContentTypeText, Text: "mock keyword search result"}}}
+ },
+ },
+ }
+
+ loop := NewLoop(Config{
+ LLM: service,
+ History: []llm.Message{},
+ Tools: tools,
+ RecordMessage: recordMessage,
+ })
+
+ // Send a user message that will trigger the default response
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Please search for some files"},
+ },
+ }
+
+ loop.QueueUserMessage(userMessage)
+
+ // Process one turn
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := loop.ProcessOneTurn(ctx)
+ if err != nil {
+ t.Fatalf("ProcessOneTurn failed: %v", err)
+ }
+
+ // Verify we got expected messages
+ // Note: User messages are recorded by ConversationManager, not by Loop,
+ // so we only expect the assistant response to be recorded here
+ if len(messages) < 1 {
+ t.Fatalf("Expected at least 1 message (assistant), got %d", len(messages))
+ }
+
+ // Should have assistant response
+ if messages[0].Role != llm.MessageRoleAssistant {
+ t.Errorf("Expected first recorded message to be assistant, got %s", messages[0].Role)
+ }
+
+ t.Log("Keyword tool test passed - no nil pointer dereference occurred")
+}
+
+func TestKeywordToolWithLLMProvider(t *testing.T) {
+ // Create a temp directory with a test file to search
+ tempDir := t.TempDir()
+ testFile := filepath.Join(tempDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("this is a test file\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a predictable service for testing
+ predictableService := NewPredictableService()
+
+ // Create a simple LLM provider for testing
+ llmProvider := &testLLMProvider{
+ service: predictableService,
+ models: []string{"predictable"},
+ }
+
+ // Create keyword tool with provider - use temp dir instead of /
+ keywordTool := claudetool.NewKeywordToolWithWorkingDir(llmProvider, claudetool.NewMutableWorkingDir(tempDir))
+ tool := keywordTool.Tool()
+
+ // Test input
+ input := `{"query": "test search", "search_terms": ["test"]}`
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ result := tool.Run(ctx, json.RawMessage(input))
+
+ // Should get a result without error (even though ripgrep will fail in test environment)
+ // The important thing is that it doesn't crash with nil pointer dereference
+ if result.Error != nil {
+ t.Logf("Expected error in test environment (no ripgrep): %v", result.Error)
+ // This is expected in test environment
+ } else {
+ t.Log("Keyword tool executed successfully")
+ if len(result.LLMContent) == 0 {
+ t.Error("Expected some content in result")
+ }
+ }
+}
+
+// testLLMProvider implements LLMServiceProvider for testing
+type testLLMProvider struct {
+ service llm.Service
+ models []string
+}
+
+func (t *testLLMProvider) GetService(modelID string) (llm.Service, error) {
+ for _, model := range t.models {
+ if model == modelID {
+ return t.service, nil
+ }
+ }
+ return nil, fmt.Errorf("model %s not available", modelID)
+}
+
+func (t *testLLMProvider) GetAvailableModels() []string {
+ return t.models
+}
+
+func TestInsertMissingToolResults(t *testing.T) {
+ tests := []struct {
+ name string
+ messages []llm.Message
+ wantLen int
+ wantText string
+ }{
+ {
+ name: "no missing tool results",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Let me help you"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Thanks"},
+ },
+ },
+ },
+ wantLen: 1,
+ wantText: "", // No synthetic result expected
+ },
+ {
+ name: "missing tool result - should insert synthetic result",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll use a tool"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_123", ToolName: "bash"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Error occurred"},
+ },
+ },
+ },
+ wantLen: 2, // Should have synthetic tool_result + error message
+ wantText: "not executed; retry possible",
+ },
+ {
+ name: "multiple missing tool results",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll use multiple tools"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_1", ToolName: "bash"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_2", ToolName: "read"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Error occurred"},
+ },
+ },
+ },
+ wantLen: 3, // Should have 2 synthetic tool_results + error message
+ },
+ {
+ name: "has tool results - should not insert",
+ messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll use a tool"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_123", ToolName: "bash"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "tool_123",
+ ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "result"}},
+ },
+ },
+ },
+ },
+ wantLen: 1, // Should not insert anything
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ req := &llm.Request{
+ Messages: tt.messages,
+ }
+
+ loop.insertMissingToolResults(req)
+
+ got := req.Messages[len(req.Messages)-1]
+ if len(got.Content) != tt.wantLen {
+ t.Errorf("expected %d content items, got %d", tt.wantLen, len(got.Content))
+ }
+
+ if tt.wantText != "" {
+ // Find the synthetic tool result
+ found := false
+ for _, c := range got.Content {
+ if c.Type == llm.ContentTypeToolResult && len(c.ToolResult) > 0 {
+ if c.ToolResult[0].Text == tt.wantText {
+ found = true
+ if !c.ToolError {
+ t.Error("synthetic tool result should have ToolError=true")
+ }
+ break
+ }
+ }
+ }
+ if !found {
+ t.Errorf("expected to find synthetic tool result with text %q", tt.wantText)
+ }
+ }
+ })
+ }
+}
+
+func TestInsertMissingToolResultsWithEdgeCases(t *testing.T) {
+ // Test for the bug: when an assistant error message is recorded after a tool_use
+ // but before tool execution, the tool_use is "hidden" from insertMissingToolResults
+ // because it only checks the last two messages.
+ t.Run("tool_use hidden by subsequent assistant message", func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ // Scenario:
+ // 1. LLM responds with tool_use
+ // 2. Something fails, error message recorded (assistant message)
+ // 3. User sends new message
+ // The tool_use in message 0 is never followed by a tool_result
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll run a command"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_hidden", ToolName: "bash"},
+ },
+ },
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "LLM request failed: some error"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Please try again"},
+ },
+ },
+ },
+ }
+
+ loop.insertMissingToolResults(req)
+
+ // The function should have inserted a tool_result for tool_hidden
+ // It should be inserted as a user message after the assistant message with tool_use
+ // Since we can't insert in the middle, we need to ensure the history is valid
+
+ // Check that there's a tool_result for tool_hidden somewhere in the messages
+ found := false
+ for _, msg := range req.Messages {
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult && c.ToolUseID == "tool_hidden" {
+ found = true
+ if !c.ToolError {
+ t.Error("synthetic tool result should have ToolError=true")
+ }
+ break
+ }
+ }
+ }
+ if !found {
+ t.Error("expected to find synthetic tool result for tool_hidden - the bug is that tool_use is hidden by subsequent assistant message")
+ }
+ })
+
+ // Test for tool_use in earlier message (not the second-to-last)
+ t.Run("tool_use in earlier message without result", func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Do something"},
+ },
+ },
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll use a tool"},
+ {Type: llm.ContentTypeToolUse, ID: "tool_earlier", ToolName: "bash"},
+ },
+ },
+ // Missing: user message with tool_result for tool_earlier
+ {
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Something went wrong"},
+ },
+ },
+ {
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Try again"},
+ },
+ },
+ },
+ }
+
+ loop.insertMissingToolResults(req)
+
+ // Should have inserted a tool_result for tool_earlier
+ found := false
+ for _, msg := range req.Messages {
+ for _, c := range msg.Content {
+ if c.Type == llm.ContentTypeToolResult && c.ToolUseID == "tool_earlier" {
+ found = true
+ break
+ }
+ }
+ }
+ if !found {
+ t.Error("expected to find synthetic tool result for tool_earlier")
+ }
+ })
+
+ t.Run("empty message list", func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ req := &llm.Request{
+ Messages: []llm.Message{},
+ }
+
+ loop.insertMissingToolResults(req)
+ // Should not panic
+ })
+
+ t.Run("single message", func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+ },
+ }
+
+ loop.insertMissingToolResults(req)
+ // Should not panic, should not modify
+ if len(req.Messages[0].Content) != 1 {
+ t.Error("should not modify single message")
+ }
+ })
+
+ t.Run("wrong role order - user then assistant", func(t *testing.T) {
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ })
+
+ req := &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+ {Role: llm.MessageRoleAssistant, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hi"}}},
+ },
+ }
+
+ loop.insertMissingToolResults(req)
+ // Should not modify when roles are wrong order
+ if len(req.Messages[1].Content) != 1 {
+ t.Error("should not modify when roles are in wrong order")
+ }
+ })
+}
@@ -0,0 +1,555 @@
+package loop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "shelley.exe.dev/llm"
+)
+
+// PredictableService is an LLM service that returns predictable responses for testing.
+//
+// To add new test patterns, update the Do() method directly by adding cases to the switch
+// statement or new prefix checks. Do not extend or wrap this service - modify it in place.
+// Available patterns include:
+// - "echo: <text>" - echoes the text back
+// - "bash: <command>" - triggers bash tool with command
+// - "think: <thoughts>" - triggers think tool
+// - "delay: <seconds>" - delays response by specified seconds
+// - See Do() method for complete list of supported patterns
+type PredictableService struct {
+ // TokenContextWindow size
+ tokenContextWindow int
+ mu sync.Mutex
+ // Recent requests for testing inspection
+ recentRequests []*llm.Request
+ responseDelay time.Duration
+}
+
+// NewPredictableService creates a new predictable LLM service
+func NewPredictableService() *PredictableService {
+ svc := &PredictableService{
+ tokenContextWindow: 200000,
+ }
+
+ if delayEnv := os.Getenv("PREDICTABLE_DELAY_MS"); delayEnv != "" {
+ if ms, err := strconv.Atoi(delayEnv); err == nil && ms > 0 {
+ svc.responseDelay = time.Duration(ms) * time.Millisecond
+ }
+ }
+
+ return svc
+}
+
+// TokenContextWindow returns the maximum token context window size
+func (s *PredictableService) TokenContextWindow() int {
+ return s.tokenContextWindow
+}
+
+// Do processes a request and returns a predictable response based on the input text
+func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
+ // Store request for testing inspection
+ s.mu.Lock()
+ delay := s.responseDelay
+ s.recentRequests = append(s.recentRequests, req)
+ // Keep only last 10 requests
+ if len(s.recentRequests) > 10 {
+ s.recentRequests = s.recentRequests[len(s.recentRequests)-10:]
+ }
+ s.mu.Unlock()
+
+ if delay > 0 {
+ select {
+ case <-time.After(delay):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+
+ // Calculate input token count based on the request content
+ inputTokens := s.countRequestTokens(req)
+
+ // Extract the text content from the last user message
+ var inputText string
+ if len(req.Messages) > 0 {
+ lastMessage := req.Messages[len(req.Messages)-1]
+ if lastMessage.Role == llm.MessageRoleUser {
+ for _, content := range lastMessage.Content {
+ if content.Type == llm.ContentTypeText {
+ inputText = strings.TrimSpace(content.Text)
+ break
+ }
+ }
+ }
+ }
+
+ // Handle input using case statements
+ switch inputText {
+ case "hello":
+ return s.makeResponse("Well, hi there!", inputTokens), nil
+
+ case "Hello":
+ return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
+
+ case "Create an example":
+ return s.makeThinkToolResponse("I'll create a simple example for you.", inputTokens), nil
+
+ case "screenshot":
+ // Trigger a screenshot of the current page
+ return s.makeScreenshotToolResponse("", inputTokens), nil
+
+ case "tool smorgasbord":
+ // Return a response with all tool types for testing
+ return s.makeToolSmorgasbordResponse(inputTokens), nil
+
+ case "echo: foo":
+ return s.makeResponse("foo", inputTokens), nil
+
+ case "patch fail":
+ // Trigger a patch that will fail (file doesn't exist)
+ return s.makePatchToolResponse("/nonexistent/file/that/does/not/exist.txt", inputTokens), nil
+
+ case "patch bad json":
+ // Trigger a patch with malformed JSON (simulates Anthropic sending invalid JSON)
+ return s.makeMalformedPatchToolResponse(inputTokens), nil
+
+ default:
+ // Handle pattern-based inputs
+ if strings.HasPrefix(inputText, "echo: ") {
+ text := strings.TrimPrefix(inputText, "echo: ")
+ return s.makeResponse(text, inputTokens), nil
+ }
+
+ if strings.HasPrefix(inputText, "bash: ") {
+ cmd := strings.TrimPrefix(inputText, "bash: ")
+ return s.makeBashToolResponse(cmd, inputTokens), nil
+ }
+
+ if strings.HasPrefix(inputText, "think: ") {
+ thoughts := strings.TrimPrefix(inputText, "think: ")
+ return s.makeThinkToolResponse(thoughts, inputTokens), nil
+ }
+
+ if strings.HasPrefix(inputText, "patch: ") {
+ filePath := strings.TrimPrefix(inputText, "patch: ")
+ return s.makePatchToolResponse(filePath, inputTokens), nil
+ }
+
+ if strings.HasPrefix(inputText, "error: ") {
+ errorMsg := strings.TrimPrefix(inputText, "error: ")
+ return nil, fmt.Errorf("predictable error: %s", errorMsg)
+ }
+
+ if strings.HasPrefix(inputText, "screenshot: ") {
+ selector := strings.TrimSpace(strings.TrimPrefix(inputText, "screenshot: "))
+ return s.makeScreenshotToolResponse(selector, inputTokens), nil
+ }
+
+ if strings.HasPrefix(inputText, "delay: ") {
+ delayStr := strings.TrimPrefix(inputText, "delay: ")
+ delaySeconds, err := strconv.ParseFloat(delayStr, 64)
+ if err == nil && delaySeconds > 0 {
+ delayDuration := time.Duration(delaySeconds * float64(time.Second))
+ select {
+ case <-time.After(delayDuration):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ return s.makeResponse(fmt.Sprintf("Delayed for %s seconds", delayStr), inputTokens), nil
+ }
+
+ // Default response for undefined inputs
+ return s.makeResponse("edit predictable.go to add a response for that one...", inputTokens), nil
+ }
+}
+
+// makeResponse creates a simple text response
+func (s *PredictableService) makeResponse(text string, inputTokens uint64) *llm.Response {
+ outputTokens := uint64(len(text) / 4) // ~4 chars per token
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: text},
+ },
+ StopReason: llm.StopReasonStopSequence,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.001,
+ },
+ }
+}
+
+// makeBashToolResponse creates a response that calls the bash tool
+func (s *PredictableService) makeBashToolResponse(command string, inputTokens uint64) *llm.Response {
+ // Properly marshal the command to avoid JSON escaping issues
+ toolInputData := map[string]string{"command": command}
+ toolInputBytes, _ := json.Marshal(toolInputData)
+ toolInput := json.RawMessage(toolInputBytes)
+ responseText := fmt.Sprintf("I'll run the command: %s", command)
+ outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-bash-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: responseText},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "bash",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.002,
+ },
+ }
+}
+
+// makeThinkToolResponse creates a response that calls the think tool
+func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
+ // Properly marshal the thoughts to avoid JSON escaping issues
+ toolInputData := map[string]string{"thoughts": thoughts}
+ toolInputBytes, _ := json.Marshal(toolInputData)
+ toolInput := json.RawMessage(toolInputBytes)
+ responseText := "Let me think about this."
+ outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: responseText},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "think",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.002,
+ },
+ }
+}
+
+// makePatchToolResponse creates a response that calls the patch tool
+func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
+ // Properly marshal the patch data to avoid JSON escaping issues
+ toolInputData := map[string]interface{}{
+ "path": filePath,
+ "patches": []map[string]string{
+ {
+ "operation": "replace",
+ "oldText": "example",
+ "newText": "updated example",
+ },
+ },
+ }
+ toolInputBytes, _ := json.Marshal(toolInputData)
+ toolInput := json.RawMessage(toolInputBytes)
+ responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
+ outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: responseText},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "patch",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.003,
+ },
+ }
+}
+
+// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
+// This simulates when Anthropic sends back invalid JSON in the tool input
+func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
+ // This malformed JSON has a string where an object is expected (patch field)
+ // Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
+ malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n color: red;\n}","newText":".example {\n color: blue;\n}"}`
+ toolInput := json.RawMessage(malformedJSON)
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "patch",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: 50,
+ CostUSD: 0.003,
+ },
+ }
+}
+
+// GetRecentRequests returns the recent requests made to this service
+func (s *PredictableService) GetRecentRequests() []*llm.Request {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if len(s.recentRequests) == 0 {
+ return nil
+ }
+
+ requests := make([]*llm.Request, len(s.recentRequests))
+ copy(requests, s.recentRequests)
+ return requests
+}
+
+// GetLastRequest returns the most recent request, or nil if none
+func (s *PredictableService) GetLastRequest() *llm.Request {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if len(s.recentRequests) == 0 {
+ return nil
+ }
+ return s.recentRequests[len(s.recentRequests)-1]
+}
+
+// ClearRequests clears the request history
+func (s *PredictableService) ClearRequests() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.recentRequests = nil
+}
+
+// countRequestTokens estimates token count based on character count.
+// Uses a simple ~4 chars per token approximation.
+func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
+ var totalChars int
+
+ // Count system prompt characters
+ for _, sys := range req.System {
+ totalChars += len(sys.Text)
+ }
+
+ // Count message characters
+ for _, msg := range req.Messages {
+ for _, content := range msg.Content {
+ switch content.Type {
+ case llm.ContentTypeText:
+ totalChars += len(content.Text)
+ case llm.ContentTypeToolUse:
+ totalChars += len(content.ToolName)
+ totalChars += len(content.ToolInput)
+ case llm.ContentTypeToolResult:
+ for _, result := range content.ToolResult {
+ if result.Type == llm.ContentTypeText {
+ totalChars += len(result.Text)
+ }
+ }
+ }
+ }
+ }
+
+ // Count tool definitions
+ for _, tool := range req.Tools {
+ totalChars += len(tool.Name)
+ totalChars += len(tool.Description)
+ totalChars += len(tool.InputSchema)
+ }
+
+ // ~4 chars per token is a rough approximation
+ return uint64(totalChars / 4)
+}
+
+// makeScreenshotToolResponse creates a response that calls the screenshot tool
+func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
+ toolInputData := map[string]any{}
+ if selector != "" {
+ toolInputData["selector"] = selector
+ }
+ toolInputBytes, _ := json.Marshal(toolInputData)
+ toolInput := json.RawMessage(toolInputBytes)
+ responseText := "Taking a screenshot..."
+ outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: responseText},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "browser_take_screenshot",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.0,
+ },
+ }
+}
+
+// makeToolSmorgasbordResponse creates a response that uses all available tool types
+func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
+ baseNano := time.Now().UnixNano()
+ content := []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
+ }
+
+ // bash tool
+ bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_bash_%d", baseNano%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "bash",
+ ToolInput: json.RawMessage(bashInput),
+ })
+
+ // think tool
+ thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "think",
+ ToolInput: json.RawMessage(thinkInput),
+ })
+
+ // patch tool
+ patchInput, _ := json.Marshal(map[string]interface{}{
+ "path": "/tmp/example.txt",
+ "patches": []map[string]string{
+ {"operation": "replace", "oldText": "foo", "newText": "bar"},
+ },
+ })
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "patch",
+ ToolInput: json.RawMessage(patchInput),
+ })
+
+ // screenshot tool
+ screenshotInput, _ := json.Marshal(map[string]string{})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "browser_take_screenshot",
+ ToolInput: json.RawMessage(screenshotInput),
+ })
+
+ // keyword_search tool
+ keywordInput, _ := json.Marshal(map[string]interface{}{
+ "query": "find all references",
+ "search_terms": []string{"reference", "example"},
+ })
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "keyword_search",
+ ToolInput: json.RawMessage(keywordInput),
+ })
+
+ // browser_navigate tool
+ navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "browser_navigate",
+ ToolInput: json.RawMessage(navigateInput),
+ })
+
+ // browser_eval tool
+ evalInput, _ := json.Marshal(map[string]string{"script": "document.title"})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "browser_eval",
+ ToolInput: json.RawMessage(evalInput),
+ })
+
+ // read_image tool
+ readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "read_image",
+ ToolInput: json.RawMessage(readImageInput),
+ })
+
+ // browser_recent_console_logs tool
+ consoleInput, _ := json.Marshal(map[string]string{})
+ content = append(content, llm.Content{
+ ID: fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "browser_recent_console_logs",
+ ToolInput: json.RawMessage(consoleInput),
+ })
+
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-smorgasbord-%d", baseNano),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: content,
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: 200,
+ CostUSD: 0.01,
+ },
+ }
+}
@@ -0,0 +1,477 @@
+package models
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/ant"
+ "shelley.exe.dev/llm/oai"
+ "shelley.exe.dev/loop"
+)
+
+// Provider represents an LLM provider
+type Provider string
+
+const (
+ ProviderOpenAI Provider = "OpenAI"
+ ProviderAnthropic Provider = "Anthropic"
+ ProviderFireworks Provider = "Fireworks"
+ ProviderGemini Provider = "Gemini"
+ ProviderBuiltIn Provider = "Built-in"
+)
+
+// Model represents a configured LLM model in Shelley
+type Model struct {
+ // ID is the user-facing identifier for this model
+ ID string
+
+ // Provider is the LLM provider (OpenAI, Anthropic, etc.)
+ Provider Provider
+
+ // Description is a human-readable description
+ Description string
+
+ // RequiredEnvVars are the environment variables required for this model
+ RequiredEnvVars []string
+
+ // Factory creates an llm.Service instance for this model
+ Factory func(config *Config) (llm.Service, error)
+}
+
+// Config holds the configuration needed to create LLM services
+type Config struct {
+ // API keys for each provider
+ AnthropicAPIKey string
+ OpenAIAPIKey string
+ GeminiAPIKey string
+ FireworksAPIKey string
+
+ // Gateway is the base URL of the LLM gateway (optional)
+ // If set, model-specific suffixes will be appended
+ Gateway string
+
+ Logger *slog.Logger
+}
+
+// getAnthropicURL returns the Anthropic API URL, with gateway suffix if gateway is set
+func (c *Config) getAnthropicURL() string {
+ if c.Gateway != "" {
+ return c.Gateway + "/_/gateway/anthropic/v1/messages"
+ }
+ return "" // use default from ant package
+}
+
+// getOpenAIURL returns the OpenAI API URL, with gateway suffix if gateway is set
+func (c *Config) getOpenAIURL() string {
+ if c.Gateway != "" {
+ return c.Gateway + "/_/gateway/openai/v1"
+ }
+ return "" // use default from oai package
+}
+
+// getGeminiURL returns the Gemini API URL, with gateway suffix if gateway is set
+func (c *Config) getGeminiURL() string {
+ if c.Gateway != "" {
+ return c.Gateway + "/_/gateway/gemini/v1/models/generate"
+ }
+ return "" // use default from gem package
+}
+
+// getFireworksURL returns the Fireworks API URL, with gateway suffix if gateway is set
+func (c *Config) getFireworksURL() string {
+ if c.Gateway != "" {
+ return c.Gateway + "/_/gateway/fireworks/inference/v1"
+ }
+ return "" // use default from oai package
+}
+
+// All returns all available models in Shelley
+func All() []Model {
+ return []Model{
+ {
+ ID: "claude-opus-4.5",
+ Provider: ProviderAnthropic,
+ Description: "Claude Opus 4.5 (default)",
+ RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.AnthropicAPIKey == "" {
+ return nil, fmt.Errorf("claude-opus-4.5 requires ANTHROPIC_API_KEY")
+ }
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus}
+ if url := config.getAnthropicURL(); url != "" {
+ svc.URL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "qwen3-coder-fireworks",
+ Provider: ProviderFireworks,
+ Description: "Qwen3 Coder 480B on Fireworks",
+ RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.FireworksAPIKey == "" {
+ return nil, fmt.Errorf("qwen3-coder-fireworks requires FIREWORKS_API_KEY")
+ }
+ svc := &oai.Service{Model: oai.Qwen3CoderFireworks, APIKey: config.FireworksAPIKey}
+ if url := config.getFireworksURL(); url != "" {
+ svc.ModelURL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "glm-4p6-fireworks",
+ Provider: ProviderFireworks,
+ Description: "GLM-4P6 on Fireworks",
+ RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.FireworksAPIKey == "" {
+ return nil, fmt.Errorf("glm-4p6-fireworks requires FIREWORKS_API_KEY")
+ }
+ svc := &oai.Service{Model: oai.GLM4P6Fireworks, APIKey: config.FireworksAPIKey}
+ if url := config.getFireworksURL(); url != "" {
+ svc.ModelURL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "gpt-5",
+ Provider: ProviderOpenAI,
+ Description: "GPT-5",
+ RequiredEnvVars: []string{"OPENAI_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.OpenAIAPIKey == "" {
+ return nil, fmt.Errorf("gpt-5 requires OPENAI_API_KEY")
+ }
+ svc := &oai.Service{Model: oai.GPT5, APIKey: config.OpenAIAPIKey}
+ if url := config.getOpenAIURL(); url != "" {
+ svc.ModelURL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "gpt-5-nano",
+ Provider: ProviderOpenAI,
+ Description: "GPT-5 Nano",
+ RequiredEnvVars: []string{"OPENAI_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.OpenAIAPIKey == "" {
+ return nil, fmt.Errorf("gpt-5-nano requires OPENAI_API_KEY")
+ }
+ svc := &oai.Service{Model: oai.GPT5Nano, APIKey: config.OpenAIAPIKey}
+ if url := config.getOpenAIURL(); url != "" {
+ svc.ModelURL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "gpt-5.1-codex",
+ Provider: ProviderOpenAI,
+ Description: "GPT-5.1 Codex (uses Responses API)",
+ RequiredEnvVars: []string{"OPENAI_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.OpenAIAPIKey == "" {
+ return nil, fmt.Errorf("gpt-5.1-codex requires OPENAI_API_KEY")
+ }
+ svc := &oai.ResponsesService{Model: oai.GPT5Codex, APIKey: config.OpenAIAPIKey}
+ if url := config.getOpenAIURL(); url != "" {
+ svc.ModelURL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "claude-sonnet-4.5",
+ Provider: ProviderAnthropic,
+ Description: "Claude Sonnet 4.5",
+ RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.AnthropicAPIKey == "" {
+ return nil, fmt.Errorf("claude-sonnet-4.5 requires ANTHROPIC_API_KEY")
+ }
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet}
+ if url := config.getAnthropicURL(); url != "" {
+ svc.URL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "claude-haiku-4.5",
+ Provider: ProviderAnthropic,
+ Description: "Claude Haiku 4.5",
+ RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+ Factory: func(config *Config) (llm.Service, error) {
+ if config.AnthropicAPIKey == "" {
+ return nil, fmt.Errorf("claude-haiku-4.5 requires ANTHROPIC_API_KEY")
+ }
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku}
+ if url := config.getAnthropicURL(); url != "" {
+ svc.URL = url
+ }
+ return svc, nil
+ },
+ },
+ {
+ ID: "predictable",
+ Provider: ProviderBuiltIn,
+ Description: "Deterministic test model (no API key)",
+ RequiredEnvVars: []string{},
+ Factory: func(config *Config) (llm.Service, error) {
+ return loop.NewPredictableService(), nil
+ },
+ },
+ }
+}
+
+// ByID returns the model with the given ID, or nil if not found
+func ByID(id string) *Model {
+ for _, m := range All() {
+ if m.ID == id {
+ return &m
+ }
+ }
+ return nil
+}
+
+// IDs returns all model IDs (not including aliases)
+func IDs() []string {
+ models := All()
+ ids := make([]string, len(models))
+ for i, m := range models {
+ ids[i] = m.ID
+ }
+ return ids
+}
+
+// Default returns the default model
+func Default() Model {
+ return All()[0] // claude-opus-4.5
+}
+
+// Manager manages LLM services for all configured models
+type Manager struct {
+ services map[string]llm.Service
+ logger *slog.Logger
+ history *LLMRequestHistory
+}
+
+// LLMRequestRecord stores a request/response pair for debugging
+type LLMRequestRecord struct {
+ Timestamp time.Time `json:"timestamp"`
+ ModelID string `json:"model_id"`
+ URL string `json:"url"`
+ HTTPRequest []byte `json:"http_request,omitempty"`
+ HTTPResponse []byte `json:"http_response,omitempty"`
+ HTTPStatusCode int `json:"http_status_code,omitempty"`
+ Error string `json:"error,omitempty"`
+ Duration float64 `json:"duration_seconds"`
+}
+
+// LLMRequestHistory maintains a circular buffer of recent LLM requests
+type LLMRequestHistory struct {
+ mu sync.RWMutex
+ records []LLMRequestRecord
+ maxSize int
+}
+
+// NewLLMRequestHistory creates a new request history with the given max size
+func NewLLMRequestHistory(maxSize int) *LLMRequestHistory {
+ return &LLMRequestHistory{
+ records: make([]LLMRequestRecord, 0, maxSize),
+ maxSize: maxSize,
+ }
+}
+
+// Add adds a new record to the history
+func (h *LLMRequestHistory) Add(record LLMRequestRecord) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if len(h.records) >= h.maxSize {
+ // Remove oldest record
+ h.records = h.records[1:]
+ }
+ h.records = append(h.records, record)
+}
+
+// GetRecords returns a copy of all records
+func (h *LLMRequestHistory) GetRecords() []LLMRequestRecord {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ result := make([]LLMRequestRecord, len(h.records))
+ copy(result, h.records)
+ return result
+}
+
+// ConfigInfo is an optional interface that services can implement to provide configuration details for logging
+type ConfigInfo interface {
+ // ConfigDetails returns human-readable configuration info (e.g., URL, model name)
+ ConfigDetails() map[string]string
+}
+
+// loggingService wraps an llm.Service to log request completion with usage information
+type loggingService struct {
+ service llm.Service
+ logger *slog.Logger
+ modelID string
+ history *LLMRequestHistory
+}
+
+// Do wraps the underlying service's Do method with logging
+func (l *loggingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) {
+ start := time.Now()
+
+ // Call the underlying service
+ response, err := l.service.Do(ctx, request)
+
+ duration := time.Since(start)
+ durationSeconds := duration.Seconds()
+
+ // History recording now happens in the provider (e.g., ant.Service)
+ // to capture raw HTTP requests/responses
+
+ // Log the completion with usage information
+ if err != nil {
+ logAttrs := []any{
+ "model", l.modelID,
+ "duration_seconds", durationSeconds,
+ }
+
+ // Add configuration details if available
+ if configProvider, ok := l.service.(ConfigInfo); ok {
+ for k, v := range configProvider.ConfigDetails() {
+ logAttrs = append(logAttrs, k, v)
+ }
+ }
+
+ logAttrs = append(logAttrs, "error", err)
+ l.logger.Error("LLM request failed", logAttrs...)
+ } else {
+ // Log successful completion with usage info
+ logAttrs := []any{
+ "model", l.modelID,
+ "duration_seconds", durationSeconds,
+ }
+
+ // Add usage information if available
+ if !response.Usage.IsZero() {
+ logAttrs = append(logAttrs,
+ "input_tokens", response.Usage.InputTokens,
+ "output_tokens", response.Usage.OutputTokens,
+ "cost_usd", response.Usage.CostUSD,
+ )
+ if response.Usage.CacheCreationInputTokens > 0 {
+ logAttrs = append(logAttrs, "cache_creation_input_tokens", response.Usage.CacheCreationInputTokens)
+ }
+ if response.Usage.CacheReadInputTokens > 0 {
+ logAttrs = append(logAttrs, "cache_read_input_tokens", response.Usage.CacheReadInputTokens)
+ }
+ }
+
+ l.logger.Info("LLM request completed", logAttrs...)
+ }
+
+ return response, err
+}
+
+// TokenContextWindow delegates to the underlying service
+func (l *loggingService) TokenContextWindow() int {
+ return l.service.TokenContextWindow()
+}
+
+// UseSimplifiedPatch delegates to the underlying service if it supports it
+func (l *loggingService) UseSimplifiedPatch() bool {
+ if sp, ok := l.service.(llm.SimplifiedPatcher); ok {
+ return sp.UseSimplifiedPatch()
+ }
+ return false
+}
+
+// NewManager creates a new Manager with all models configured
+func NewManager(cfg *Config, history *LLMRequestHistory) (*Manager, error) {
+ manager := &Manager{
+ services: make(map[string]llm.Service),
+ logger: cfg.Logger,
+ history: history,
+ }
+
+ for _, model := range All() {
+ svc, err := model.Factory(cfg)
+ if err != nil {
+ // Model not available (e.g., missing API key) - skip it
+ continue
+ }
+ manager.services[model.ID] = svc
+ }
+
+ return manager, nil
+}
+
+// GetService returns the LLM service for the given model ID, wrapped with logging
+func (m *Manager) GetService(modelID string) (llm.Service, error) {
+ if svc, ok := m.services[modelID]; ok {
+ // Set HTTP recorder on ant.Service if we have history
+ if antSvc, ok := svc.(*ant.Service); ok && m.history != nil {
+ antSvc.HTTPRecorder = func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+ record := LLMRequestRecord{
+ Timestamp: time.Now().Add(-duration),
+ ModelID: modelID,
+ URL: url,
+ HTTPRequest: requestBody,
+ HTTPResponse: responseBody,
+ HTTPStatusCode: statusCode,
+ Duration: duration.Seconds(),
+ }
+ if err != nil {
+ record.Error = err.Error()
+ }
+ m.history.Add(record)
+ }
+ }
+ // Wrap with logging if we have a logger
+ if m.logger != nil {
+ return &loggingService{
+ service: svc,
+ logger: m.logger,
+ modelID: modelID,
+ history: m.history,
+ }, nil
+ }
+ return svc, nil
+ }
+ return nil, fmt.Errorf("unsupported model: %s", modelID)
+}
+
+// GetHistory returns the LLM request history
+func (m *Manager) GetHistory() *LLMRequestHistory {
+ return m.history
+}
+
+// GetAvailableModels returns a list of available model IDs in the same order as All()
+func (m *Manager) GetAvailableModels() []string {
+ // Return IDs in the same order as All() for consistency
+ all := All()
+ var ids []string
+ for _, model := range all {
+ if _, ok := m.services[model.ID]; ok {
+ ids = append(ids, model.ID)
+ }
+ }
+ return ids
+}
+
+// HasModel reports whether the manager has a service for the given model ID
+func (m *Manager) HasModel(modelID string) bool {
+ _, ok := m.services[modelID]
+ return ok
+}
@@ -0,0 +1,172 @@
+package models
+
+import (
+ "testing"
+)
+
+func TestAll(t *testing.T) {
+ models := All()
+ if len(models) == 0 {
+ t.Fatal("expected at least one model")
+ }
+
+ // Verify all models have required fields
+ for _, m := range models {
+ if m.ID == "" {
+ t.Errorf("model missing ID")
+ }
+ if m.Provider == "" {
+ t.Errorf("model %s missing Provider", m.ID)
+ }
+ if m.Factory == nil {
+ t.Errorf("model %s missing Factory", m.ID)
+ }
+ }
+}
+
+func TestByID(t *testing.T) {
+ tests := []struct {
+ id string
+ wantID string
+ wantNil bool
+ }{
+ {id: "qwen3-coder-fireworks", wantID: "qwen3-coder-fireworks", wantNil: false},
+ {id: "gpt-5", wantID: "gpt-5", wantNil: false},
+ {id: "claude-sonnet-4.5", wantID: "claude-sonnet-4.5", wantNil: false},
+ {id: "claude-haiku-4.5", wantID: "claude-haiku-4.5", wantNil: false},
+ {id: "claude-opus-4.5", wantID: "claude-opus-4.5", wantNil: false},
+ {id: "nonexistent", wantNil: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.id, func(t *testing.T) {
+ m := ByID(tt.id)
+ if tt.wantNil {
+ if m != nil {
+ t.Errorf("ByID(%q) = %v, want nil", tt.id, m)
+ }
+ } else {
+ if m == nil {
+ t.Fatalf("ByID(%q) = nil, want non-nil", tt.id)
+ }
+ if m.ID != tt.wantID {
+ t.Errorf("ByID(%q).ID = %q, want %q", tt.id, m.ID, tt.wantID)
+ }
+ }
+ })
+ }
+}
+
+func TestDefault(t *testing.T) {
+ d := Default()
+ if d.ID != "claude-opus-4.5" {
+ t.Errorf("Default().ID = %q, want %q", d.ID, "claude-opus-4.5")
+ }
+}
+
+func TestIDs(t *testing.T) {
+ ids := IDs()
+ if len(ids) == 0 {
+ t.Fatal("expected at least one model ID")
+ }
+
+ // Verify all IDs are unique
+ seen := make(map[string]bool)
+ for _, id := range ids {
+ if seen[id] {
+ t.Errorf("duplicate model ID: %s", id)
+ }
+ seen[id] = true
+ }
+}
+
+func TestFactory(t *testing.T) {
+ // Test that we can create services with empty config (should fail for most models)
+ cfg := &Config{}
+
+ // Predictable should work without any config
+ m := ByID("predictable")
+ if m == nil {
+ t.Fatal("predictable model not found")
+ }
+
+ svc, err := m.Factory(cfg)
+ if err != nil {
+ t.Fatalf("predictable Factory() failed: %v", err)
+ }
+ if svc == nil {
+ t.Fatal("predictable Factory() returned nil service")
+ }
+}
+
+func TestManagerGetAvailableModelsOrder(t *testing.T) {
+ // Test that GetAvailableModels returns models in consistent order
+ cfg := &Config{}
+
+ // Create manager - should only have predictable model since no API keys
+ manager, err := NewManager(cfg, nil)
+ if err != nil {
+ t.Fatalf("NewManager failed: %v", err)
+ }
+
+ // Get available models multiple times
+ firstCall := manager.GetAvailableModels()
+ secondCall := manager.GetAvailableModels()
+ thirdCall := manager.GetAvailableModels()
+
+ // Should return at least predictable model
+ if len(firstCall) == 0 {
+ t.Fatal("expected at least one model")
+ }
+
+ // All calls should return identical order
+ if len(firstCall) != len(secondCall) || len(firstCall) != len(thirdCall) {
+ t.Errorf("calls returned different lengths: %d, %d, %d", len(firstCall), len(secondCall), len(thirdCall))
+ }
+
+ for i := range firstCall {
+ if firstCall[i] != secondCall[i] {
+ t.Errorf("call 1 and 2 differ at index %d: %q vs %q", i, firstCall[i], secondCall[i])
+ }
+ if firstCall[i] != thirdCall[i] {
+ t.Errorf("call 1 and 3 differ at index %d: %q vs %q", i, firstCall[i], thirdCall[i])
+ }
+ }
+}
+
+func TestManagerGetAvailableModelsMatchesAllOrder(t *testing.T) {
+ // Test that available models are returned in the same order as All()
+ cfg := &Config{
+ AnthropicAPIKey: "test-key",
+ OpenAIAPIKey: "test-key",
+ GeminiAPIKey: "test-key",
+ FireworksAPIKey: "test-key",
+ }
+
+ manager, err := NewManager(cfg, nil)
+ if err != nil {
+ t.Fatalf("NewManager failed: %v", err)
+ }
+
+ available := manager.GetAvailableModels()
+ all := All()
+
+ // Build expected order from All()
+ var expected []string
+ for _, m := range all {
+ if manager.HasModel(m.ID) {
+ expected = append(expected, m.ID)
+ }
+ }
+
+ // Should match
+ if len(available) != len(expected) {
+ t.Fatalf("available models count %d != expected count %d", len(available), len(expected))
+ }
+
+ for i := range available {
+ if available[i] != expected[i] {
+ t.Errorf("model at index %d: got %q, want %q", i, available[i], expected[i])
+ }
+ }
+}
@@ -0,0 +1,13 @@
+//go:build linux && amd64
+
+package seccomp
+
+import "golang.org/x/sys/unix"
+
+const (
+ auditArch = unix.AUDIT_ARCH_X86_64
+ sysKill = 62
+ sysTkill = 200
+ sysTgkill = 234
+ sysPidfdSendSignal = 424
+)
@@ -0,0 +1,13 @@
+//go:build linux && arm64
+
+package seccomp
+
+import "golang.org/x/sys/unix"
+
+const (
+ auditArch = unix.AUDIT_ARCH_AARCH64
+ sysKill = 129
+ sysTkill = 130
+ sysTgkill = 131
+ sysPidfdSendSignal = 424
+)
@@ -0,0 +1,132 @@
+//go:build linux
+
+// Package seccomp provides a seccomp filter to prevent child processes
+// from killing the parent process.
+//
+// Note: We use raw BPF instead of github.com/seccomp/libseccomp-golang
+// because that library requires cgo and links against libseccomp.
+// This pure-Go implementation avoids the cgo dependency.
+package seccomp
+
+import (
+ "fmt"
+ "os"
+ "unsafe"
+
+ "golang.org/x/sys/unix"
+)
+
+// BPF instruction constants
+const (
+ bpfLD = 0x00
+ bpfW = 0x00
+ bpfABS = 0x20
+ bpfJMP = 0x05
+ bpfJEQ = 0x10
+ bpfRET = 0x06
+ bpfK = 0x00
+)
+
+// seccomp_data offsets
+const (
+ offsetNr = 0 // syscall number (int, 4 bytes)
+ offsetArch = 4 // architecture (u32, 4 bytes)
+ offsetArgs = 16 // args[0] starts at offset 16 (u64 each)
+)
+
+// bpfStmt creates a BPF statement (no jump targets)
+func bpfStmt(code uint16, k uint32) unix.SockFilter {
+ return unix.SockFilter{Code: code, Jt: 0, Jf: 0, K: k}
+}
+
+// bpfJump creates a BPF jump instruction
+func bpfJump(code uint16, k uint32, jt, jf uint8) unix.SockFilter {
+ return unix.SockFilter{Code: code, Jt: jt, Jf: jf, K: k}
+}
+
+// BlockKillSelf installs a seccomp filter that prevents any process from
+// sending signals to the current process via kill(2) and related syscalls
+// (tkill, tgkill).
+// This must be called before spawning child processes.
+// The filter is inherited by child processes.
+//
+// The filter is installed with SECCOMP_FILTER_FLAG_TSYNC to synchronize
+// across all threads in the process, ensuring child processes spawned
+// from any goroutine will inherit the filter.
+func BlockKillSelf() error {
+ pid := uint32(os.Getpid())
+ // Negative PID in two's complement (for blocking kill(-pid, sig) which
+ // sends signals to the process group)
+ negPid := uint32(-int32(pid))
+
+ // Build BPF filter program that blocks kill/tkill/tgkill
+ // when arg0 (target pid) matches our pid or -pid.
+ //
+ // The filter structure:
+ // 1. Load and check architecture
+ // 2. Load syscall number
+ // 3. Check if it's one of the signal-sending syscalls
+ // 4. If so, check if arg0 == our pid OR arg0 == -our pid
+ // 5. If targeting us, return EPERM; otherwise allow
+ filter := []unix.SockFilter{
+ // [0] Load architecture
+ bpfStmt(bpfLD|bpfW|bpfABS, offsetArch),
+ // [1] If not our arch, jump to allow (end of filter)
+ bpfJump(bpfJMP|bpfJEQ|bpfK, auditArch, 0, 12), // skip to ALLOW at [14]
+
+ // [2] Load syscall number
+ bpfStmt(bpfLD|bpfW|bpfABS, offsetNr),
+
+ // [3] Check for kill
+ bpfJump(bpfJMP|bpfJEQ|bpfK, sysKill, 4, 0), // match -> check pid at [8]
+ // [4] Check for tkill
+ bpfJump(bpfJMP|bpfJEQ|bpfK, sysTkill, 3, 0), // match -> check pid at [8]
+ // [5] Check for tgkill (arg0 is tgid, arg2 is tid - we check arg0)
+ bpfJump(bpfJMP|bpfJEQ|bpfK, sysTgkill, 2, 0), // match -> check pid at [8]
+
+ // [6-7] Jump to allow for non-matching syscalls
+ bpfJump(bpfJMP|bpfJEQ|bpfK, 0xFFFFFFFF, 0, 7), // never matches, always jumps to ALLOW at [14]
+ bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW), // [7] unreachable filler
+
+ // [8] Load first argument (target PID) - lower 32 bits
+ bpfStmt(bpfLD|bpfW|bpfABS, offsetArgs),
+ // [9] Check if target PID matches our PID (positive)
+ bpfJump(bpfJMP|bpfJEQ|bpfK, pid, 3, 0), // if our pid, jump to EPERM at [13]
+ // [10] Check if target PID matches -our PID (for process group kills)
+ bpfJump(bpfJMP|bpfJEQ|bpfK, negPid, 2, 0), // if -our pid, jump to EPERM at [13]
+
+ // [11] Not targeting us, allow
+ bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+
+ // [12] Unreachable filler
+ bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+
+ // [13] Return EPERM for signal syscalls targeting our process
+ bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ERRNO|uint32(unix.EPERM)),
+
+ // [14] Allow the syscall
+ bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+ }
+
+ // Set NO_NEW_PRIVS to allow unprivileged seccomp
+ if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
+ return fmt.Errorf("prctl(PR_SET_NO_NEW_PRIVS): %w", err)
+ }
+
+ // Install the seccomp filter
+ prog := unix.SockFprog{
+ Len: uint16(len(filter)),
+ Filter: &filter[0],
+ }
+
+ // Use seccomp() syscall with SECCOMP_FILTER_FLAG_TSYNC to apply the filter
+ // to all threads in the process. This ensures that child processes spawned
+ // from any goroutine (which may run on different OS threads) will inherit
+ // the filter.
+ _, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, unix.SECCOMP_FILTER_FLAG_TSYNC, uintptr(unsafe.Pointer(&prog)))
+ if errno != 0 {
+ return fmt.Errorf("seccomp(SECCOMP_SET_MODE_FILTER, TSYNC): %w", errno)
+ }
+
+ return nil
+}
@@ -0,0 +1,180 @@
+//go:build linux
+
+package seccomp
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+func TestBlockKillSelf(t *testing.T) {
+ // This test must run in a subprocess because seccomp filters are inherited
+ // by child processes and cannot be removed once installed.
+ if os.Getenv("TEST_SECCOMP_SUBPROCESS") == "1" {
+ runSeccompTestSubprocess(t)
+ return
+ }
+
+ // Re-exec this test in a subprocess
+ cmd := exec.Command(os.Args[0], "-test.run=TestBlockKillSelf$", "-test.v")
+ cmd.Env = append(os.Environ(), "TEST_SECCOMP_SUBPROCESS=1")
+ output, err := cmd.CombinedOutput()
+ t.Logf("Subprocess output:\n%s", output)
+ if err != nil {
+ t.Fatalf("Subprocess failed: %v", err)
+ }
+}
+
+func runSeccompTestSubprocess(t *testing.T) {
+ pid := os.Getpid()
+ t.Logf("Running seccomp test in subprocess with PID %d", pid)
+
+ // Install the seccomp filter
+ if err := BlockKillSelf(); err != nil {
+ t.Fatalf("BlockKillSelf failed: %v", err)
+ }
+ t.Log("Seccomp filter installed")
+
+ // Now spawn a child process that tries to kill us
+ // We use a shell command because we need a separate process
+ cmd := exec.Command("sh", "-c", "kill -TERM "+strconv.Itoa(pid)+" 2>&1; echo exit_code=$?")
+ output, _ := cmd.CombinedOutput()
+ t.Logf("Kill attempt output: %s", output)
+
+ // The kill should have failed with EPERM
+ // If we're still alive, the seccomp filter worked!
+ t.Log("We survived the kill attempt!")
+
+ // Also verify we can still kill other things (like a sleep process)
+ sleepCmd := exec.Command("sleep", "60")
+ if err := sleepCmd.Start(); err != nil {
+ t.Fatalf("Failed to start sleep: %v", err)
+ }
+ sleepPid := sleepCmd.Process.Pid
+
+ // Kill the sleep process - this should work
+ if err := syscall.Kill(sleepPid, syscall.SIGTERM); err != nil {
+ t.Errorf("Failed to kill sleep process: %v", err)
+ }
+ sleepCmd.Wait()
+ t.Logf("Successfully killed sleep process %d", sleepPid)
+
+ // Try to kill ourselves directly - this should fail
+ err := syscall.Kill(pid, syscall.SIGTERM)
+ if err == nil {
+ t.Fatal("Expected kill of self to fail, but it succeeded")
+ }
+ if err != syscall.EPERM {
+ t.Fatalf("Expected EPERM, got %v", err)
+ }
+ t.Logf("Kill of self correctly returned EPERM")
+
+ // Try to kill using negative PID (process group kill) - this should also fail
+ err = syscall.Kill(-pid, syscall.SIGTERM)
+ if err == nil {
+ t.Fatal("Expected kill of -self to fail, but it succeeded")
+ }
+ if err != syscall.EPERM {
+ t.Fatalf("Expected EPERM for negative PID, got %v", err)
+ }
+ t.Logf("Kill of -self correctly returned EPERM")
+}
+
+func TestBlockKillSelf_ChildCannotKillParent(t *testing.T) {
+ // This is the main test: verify that after installing seccomp,
+ // a child process cannot kill the parent (shelley) process.
+ if os.Getenv("TEST_SECCOMP_CHILD_SUBPROCESS") == "1" {
+ runChildCannotKillParentSubprocess(t)
+ return
+ }
+
+ // Re-exec this test in a subprocess
+ cmd := exec.Command(os.Args[0], "-test.run=TestBlockKillSelf_ChildCannotKillParent$", "-test.v")
+ cmd.Env = append(os.Environ(), "TEST_SECCOMP_CHILD_SUBPROCESS=1")
+ output, err := cmd.CombinedOutput()
+ t.Logf("Subprocess output:\n%s", output)
+ if err != nil {
+ t.Fatalf("Subprocess failed: %v", err)
+ }
+}
+
+func runChildCannotKillParentSubprocess(t *testing.T) {
+ pid := os.Getpid()
+ t.Logf("Parent process PID: %d", pid)
+
+ // Install the seccomp filter BEFORE spawning children
+ if err := BlockKillSelf(); err != nil {
+ t.Fatalf("BlockKillSelf failed: %v", err)
+ }
+ t.Log("Seccomp filter installed in parent")
+
+ // Spawn a child process that tries to kill the parent using positive PID
+ // The child inherits the seccomp filter, which blocks kill(parent_pid, ...)
+ script := fmt.Sprintf(`
+echo "Child attempting to kill parent PID %d"
+kill -TERM %d 2>&1
+result=$?
+echo "kill exit code: $result"
+if [ $result -ne 0 ]; then
+ echo "SUCCESS: kill was blocked"
+ exit 0
+else
+ echo "FAILURE: kill succeeded (parent should be dead)"
+ exit 1
+fi
+`, pid, pid)
+
+ cmd := exec.Command("sh", "-c", script)
+ output, err := cmd.CombinedOutput()
+ t.Logf("Child output (positive PID):\n%s", output)
+
+ // Check that the child reported success (kill was blocked)
+ if err != nil {
+ t.Fatalf("Child process reported failure (positive PID): %v", err)
+ }
+
+ // Verify the output contains our success message
+ if !strings.Contains(string(output), "SUCCESS: kill was blocked") {
+ t.Fatalf("Expected success message in output (positive PID)")
+ }
+
+ // We're still alive!
+ t.Logf("Parent (PID %d) survived child's positive PID kill attempt", pid)
+
+ // Now test with negative PID (process group kill)
+ negScript := fmt.Sprintf(`
+echo "Child attempting to kill parent process group with PID -%d"
+kill -TERM -%d 2>&1
+result=$?
+echo "kill exit code: $result"
+if [ $result -ne 0 ]; then
+ echo "SUCCESS: kill -pid was blocked"
+ exit 0
+else
+ echo "FAILURE: kill -pid succeeded (parent should be dead)"
+ exit 1
+fi
+`, pid, pid)
+
+ negCmd := exec.Command("sh", "-c", negScript)
+ negOutput, negErr := negCmd.CombinedOutput()
+ t.Logf("Child output (negative PID):\n%s", negOutput)
+
+ // Check that the child reported success (kill was blocked)
+ if negErr != nil {
+ t.Fatalf("Child process reported failure (negative PID): %v", negErr)
+ }
+
+ // Verify the output contains our success message
+ if !strings.Contains(string(negOutput), "SUCCESS: kill -pid was blocked") {
+ t.Fatalf("Expected success message in output (negative PID)")
+ }
+
+ // We're still alive!
+ t.Logf("Parent (PID %d) survived child's negative PID kill attempt", pid)
+}
@@ -0,0 +1,9 @@
+//go:build !linux
+
+package seccomp
+
+// BlockKillSelf is a no-op on non-Linux systems.
+// Seccomp is a Linux-specific feature.
+func BlockKillSelf() error {
+ return nil
+}
@@ -0,0 +1,997 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/llm/ant"
+)
+
+// ClaudeTestHarness extends TestHarness with Claude-specific functionality
+type ClaudeTestHarness struct {
+ t *testing.T
+ db *db.DB
+ server *Server
+ cleanup func()
+ convID string
+ timeout time.Duration
+ llmService *ant.Service
+ requestTokens []uint64 // Track total tokens for each request
+ lastMessageCount int // Track message count after last operation
+ mu sync.Mutex
+}
+
+// NewClaudeTestHarness creates a test harness that uses the real Claude API
+func NewClaudeTestHarness(t *testing.T) *ClaudeTestHarness {
+ t.Helper()
+
+ apiKey := os.Getenv("ANTHROPIC_API_KEY")
+ if apiKey == "" {
+ t.Skip("ANTHROPIC_API_KEY not set, skipping Claude test")
+ }
+
+ database, cleanup := setupTestDB(t)
+
+ // Create Claude service with HTTP recorder to track token usage
+ h := &ClaudeTestHarness{
+ t: t,
+ db: database,
+ cleanup: cleanup,
+ timeout: 60 * time.Second, // Longer timeout for real API calls
+ requestTokens: make([]uint64, 0),
+ }
+
+ service := &ant.Service{
+ APIKey: apiKey,
+ Model: ant.Claude45Haiku, // Use cheaper model for testing
+ HTTPRecorder: h.recordHTTPRequest,
+ }
+ h.llmService = service
+
+ llmManager := &claudeLLMManager{service: service}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ // Set up tools - bash for testing tool cancellation
+ toolSetConfig := claudetool.ToolSetConfig{
+ WorkingDir: t.TempDir(),
+ EnableBrowser: false,
+ }
+
+ server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "claude", "", nil)
+ h.server = server
+
+ return h
+}
+
+// recordHTTPRequest is a callback to record HTTP requests for token tracking
+func (h *ClaudeTestHarness) recordHTTPRequest(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+ h.t.Logf("HTTP callback: status=%d, err=%v, responseLen=%d", statusCode, err, len(responseBody))
+
+ if statusCode != http.StatusOK || responseBody == nil {
+ return
+ }
+
+ // Parse response to get token usage (including cache tokens)
+ var resp struct {
+ Usage struct {
+ InputTokens uint64 `json:"input_tokens"`
+ OutputTokens uint64 `json:"output_tokens"`
+ CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
+ } `json:"usage"`
+ }
+ if jsonErr := json.Unmarshal(responseBody, &resp); jsonErr == nil {
+ // Total tokens = input + cache_creation + cache_read (this represents total context)
+ totalTokens := resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens
+ h.mu.Lock()
+ h.requestTokens = append(h.requestTokens, totalTokens)
+ h.mu.Unlock()
+ h.t.Logf("Recorded request: input=%d, cache_creation=%d, cache_read=%d, total=%d",
+ resp.Usage.InputTokens, resp.Usage.CacheCreationInputTokens, resp.Usage.CacheReadInputTokens, totalTokens)
+ } else {
+ h.t.Logf("Failed to parse response: %v", jsonErr)
+ }
+}
+
+// GetRequestTokens returns a copy of recorded request token counts
+func (h *ClaudeTestHarness) GetRequestTokens() []uint64 {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ tokens := make([]uint64, len(h.requestTokens))
+ copy(tokens, h.requestTokens)
+ return tokens
+}
+
+// VerifyTokensNonDecreasing checks that tokens don't decrease below a baseline
+// This verifies that context is being preserved across requests
+func (h *ClaudeTestHarness) VerifyTokensNonDecreasing() {
+ h.t.Helper()
+ tokens := h.GetRequestTokens()
+ if len(tokens) == 0 {
+ h.t.Log("No tokens recorded, skipping token verification")
+ return
+ }
+
+ h.t.Logf("Token progression: %v", tokens)
+
+ // Find the baseline (first substantial token count, skipping small slug generation requests)
+ // Slug generation requests have ~100-200 tokens, conversation requests have 4000+
+ var baseline uint64
+ for _, t := range tokens {
+ if t > 1000 { // Skip small requests like slug generation
+ baseline = t
+ break
+ }
+ }
+
+ if baseline == 0 {
+ h.t.Log("No substantial baseline found, skipping token verification")
+ return
+ }
+
+ // Verify no substantial request drops significantly below baseline (allow 10% variance for caching)
+ minAllowed := baseline * 9 / 10
+ for i, t := range tokens {
+ if t > 1000 && t < minAllowed { // Only check substantial requests
+ h.t.Errorf("Token count at index %d dropped significantly: %d < %d (baseline=%d)", i, t, minAllowed, baseline)
+ }
+ }
+}
+
+// Close cleans up the test harness resources
+func (h *ClaudeTestHarness) Close() {
+ h.cleanup()
+}
+
+// NewConversation starts a new conversation with Claude
+func (h *ClaudeTestHarness) NewConversation(msg, cwd string) *ClaudeTestHarness {
+ h.t.Helper()
+
+ chatReq := ChatRequest{
+ Message: msg,
+ Model: "claude",
+ Cwd: cwd,
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversations/new", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ h.server.handleNewConversation(w, req)
+ if w.Code != http.StatusCreated {
+ h.t.Fatalf("NewConversation: expected status 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ ConversationID string `json:"conversation_id"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ h.t.Fatalf("NewConversation: failed to parse response: %v", err)
+ }
+ h.convID = resp.ConversationID
+
+ // Reset lastMessageCount - new conversation starts fresh
+ h.mu.Lock()
+ h.lastMessageCount = 0
+ h.mu.Unlock()
+
+ return h
+}
+
+// Chat sends a message to the current conversation
+func (h *ClaudeTestHarness) Chat(msg string) *ClaudeTestHarness {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("Chat: no conversation started, call NewConversation first")
+ }
+
+ // Record message count before sending
+ h.mu.Lock()
+ h.lastMessageCount = len(h.GetMessagesUnsafe())
+ h.mu.Unlock()
+
+ chatReq := ChatRequest{
+ Message: msg,
+ Model: "claude",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ h.server.handleChatConversation(w, req, h.convID)
+ if w.Code != http.StatusAccepted {
+ h.t.Fatalf("Chat: expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+ return h
+}
+
+// GetMessagesUnsafe gets messages without locking (internal use only)
+func (h *ClaudeTestHarness) GetMessagesUnsafe() []generated.Message {
+ var messages []generated.Message
+ h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ return messages
+}
+
+// Cancel cancels the current conversation
+func (h *ClaudeTestHarness) Cancel() *ClaudeTestHarness {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("Cancel: no conversation started")
+ }
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/cancel", nil)
+ w := httptest.NewRecorder()
+
+ h.server.handleCancelConversation(w, req, h.convID)
+ if w.Code != http.StatusOK {
+ h.t.Fatalf("Cancel: expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+ return h
+}
+
+// WaitForAgentWorking waits until the agent is working (tool call started)
+func (h *ClaudeTestHarness) WaitForAgentWorking() *ClaudeTestHarness {
+ h.t.Helper()
+
+ deadline := time.Now().Add(h.timeout)
+ for time.Now().Before(deadline) {
+ if h.isAgentWorking() {
+ return h
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ h.t.Fatal("WaitForAgentWorking: timed out waiting for agent to start working")
+ return h
+}
+
+// isAgentWorking checks if the agent is currently working
+func (h *ClaudeTestHarness) isAgentWorking() bool {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ return false
+ }
+
+ // Look for an assistant message with tool use that doesn't have a corresponding result
+ for i := len(messages) - 1; i >= 0; i-- {
+ msg := messages[i]
+ if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ // Check if this assistant message has tool use
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ // Check if there's a corresponding tool result
+ hasResult := false
+ for j := i + 1; j < len(messages); j++ {
+ nextMsg := messages[j]
+ if nextMsg.Type == string(db.MessageTypeUser) && nextMsg.LlmData != nil {
+ var userMsg llm.Message
+ if err := json.Unmarshal([]byte(*nextMsg.LlmData), &userMsg); err != nil {
+ continue
+ }
+ for _, c := range userMsg.Content {
+ if c.Type == llm.ContentTypeToolResult && c.ToolUseID == content.ID {
+ hasResult = true
+ break
+ }
+ }
+ }
+ if hasResult {
+ break
+ }
+ }
+ if !hasResult {
+ return true // Tool is in progress
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+// WaitResponse waits for the assistant's text response (end of turn)
+// It waits for a NEW response after the last Chat/NewConversation call
+func (h *ClaudeTestHarness) WaitResponse() string {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("WaitResponse: no conversation started")
+ }
+
+ h.mu.Lock()
+ minMessageCount := h.lastMessageCount
+ h.mu.Unlock()
+
+ deadline := time.Now().Add(h.timeout)
+ for time.Now().Before(deadline) {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ h.t.Fatalf("WaitResponse: failed to get messages: %v", err)
+ }
+
+ // Look for an assistant message with end_of_turn that came AFTER minMessageCount
+ // Start from the end to find the most recent one
+ for i := len(messages) - 1; i >= 0 && i >= minMessageCount; i-- {
+ msg := messages[i]
+ if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ if llmMsg.EndOfTurn {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText {
+ // Update lastMessageCount for the next wait
+ h.mu.Lock()
+ h.lastMessageCount = len(messages)
+ h.mu.Unlock()
+ return content.Text
+ }
+ }
+ }
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ h.t.Fatalf("WaitResponse: timed out waiting for response (lastMessageCount=%d)", minMessageCount)
+ return ""
+}
+
+// WaitToolResult waits for a tool result and returns its text content
+func (h *ClaudeTestHarness) WaitToolResult() string {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("WaitToolResult: no conversation started")
+ }
+
+ deadline := time.Now().Add(h.timeout)
+ for time.Now().Before(deadline) {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ h.t.Fatalf("WaitToolResult: failed to get messages: %v", err)
+ }
+
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ for _, result := range content.ToolResult {
+ if result.Type == llm.ContentTypeText && result.Text != "" {
+ return result.Text
+ }
+ }
+ }
+ }
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ h.t.Fatalf("WaitToolResult: timed out waiting for tool result")
+ return ""
+}
+
+// ConversationID returns the current conversation ID
+func (h *ClaudeTestHarness) ConversationID() string {
+ return h.convID
+}
+
+// GetMessages returns all messages in the conversation
+func (h *ClaudeTestHarness) GetMessages() []generated.Message {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ h.t.Fatalf("GetMessages: failed to get messages: %v", err)
+ }
+ return messages
+}
+
+// HasCancelledToolResult checks if there's a cancelled tool result in the conversation
+func (h *ClaudeTestHarness) HasCancelledToolResult() bool {
+ messages := h.GetMessages()
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult && content.ToolError {
+ for _, result := range content.ToolResult {
+ if result.Type == llm.ContentTypeText && strings.Contains(result.Text, "cancelled") {
+ return true
+ }
+ }
+ }
+ }
+ }
+ return false
+}
+
+// HasCancellationMessage checks if there's a cancellation message in the conversation
+func (h *ClaudeTestHarness) HasCancellationMessage() bool {
+ messages := h.GetMessages()
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "Operation cancelled") {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// claudeLLMManager is an LLMProvider that returns the Claude service
+type claudeLLMManager struct {
+ service llm.Service
+}
+
+func (m *claudeLLMManager) GetService(modelID string) (llm.Service, error) {
+ return m.service, nil
+}
+
+func (m *claudeLLMManager) GetAvailableModels() []string {
+ return []string{"claude", "claude-haiku-4.5"}
+}
+
+func (m *claudeLLMManager) HasModel(modelID string) bool {
+ return modelID == "claude" || modelID == "claude-haiku-4.5"
+}
+
+// TestClaudeCancelDuringToolCall tests cancellation during tool execution with Claude
+func TestClaudeCancelDuringToolCall(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Start a conversation that triggers a slow bash command
+ h.NewConversation("Please run the bash command: sleep 10", "")
+
+ // Wait for the tool to start executing
+ h.WaitForAgentWorking()
+ t.Log("Agent is working on tool call")
+
+ // Cancel the conversation
+ h.Cancel()
+ t.Log("Cancelled conversation")
+
+ // Wait a bit for cancellation to complete
+ time.Sleep(500 * time.Millisecond)
+
+ // Verify cancellation was recorded properly
+ if !h.HasCancelledToolResult() {
+ t.Error("expected cancelled tool result to be recorded")
+ }
+
+ if !h.HasCancellationMessage() {
+ t.Error("expected cancellation message to be recorded")
+ }
+
+ messages := h.GetMessages()
+ t.Logf("Total messages after cancellation: %d", len(messages))
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCall tests cancellation during LLM API call with Claude
+func TestClaudeCancelDuringLLMCall(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Start a conversation with a message that will take some time to process
+ h.NewConversation("Please write a very detailed essay about the history of computing, covering at least 10 major milestones.", "")
+
+ // Wait briefly for the request to be sent to Claude
+ time.Sleep(500 * time.Millisecond)
+
+ // Cancel during the LLM call
+ h.Cancel()
+ t.Log("Cancelled during LLM call")
+
+ // Wait for cancellation
+ time.Sleep(500 * time.Millisecond)
+
+ // Verify cancellation message exists
+ if !h.HasCancellationMessage() {
+ t.Error("expected cancellation message to be recorded")
+ }
+
+ messages := h.GetMessages()
+ t.Logf("Total messages after cancellation: %d", len(messages))
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallThenResume tests cancellation during LLM API call and then resuming
+func TestClaudeCancelDuringLLMCallThenResume(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Start a conversation with context we can verify later
+ h.NewConversation("Remember this code: BLUE42. Write a long essay about colors.", "")
+
+ // Wait briefly for the request to be sent to Claude
+ time.Sleep(300 * time.Millisecond)
+
+ // Cancel during the LLM call (before response arrives)
+ h.Cancel()
+ t.Log("Cancelled during LLM call")
+ time.Sleep(500 * time.Millisecond)
+
+ if !h.HasCancellationMessage() {
+ t.Error("expected cancellation message to be recorded")
+ }
+
+ tokensAfterCancel := h.GetRequestTokens()
+ t.Logf("Tokens after cancel: %v", tokensAfterCancel)
+
+ // Now resume and verify context is preserved
+ h.Chat("What was the code I asked you to remember? Just tell me the code.")
+ response := h.WaitResponse()
+ t.Logf("Response after resume: %s", response)
+
+ // Verify context was preserved - Claude should remember BLUE42
+ if !strings.Contains(strings.ToUpper(response), "BLUE42") {
+ t.Errorf("expected response to contain BLUE42, got: %s", response)
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallMultipleTimes tests multiple cancellations during LLM calls
+func TestClaudeCancelDuringLLMCallMultipleTimes(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // First: cancel during LLM call
+ h.NewConversation("Write a very long detailed story about space exploration.", "")
+ time.Sleep(300 * time.Millisecond)
+ h.Cancel()
+ t.Log("First cancel during LLM")
+ time.Sleep(500 * time.Millisecond)
+
+ // Second: cancel during LLM call again
+ h.Chat("Write a very long detailed story about ocean exploration.")
+ time.Sleep(300 * time.Millisecond)
+ h.Cancel()
+ t.Log("Second cancel during LLM")
+ time.Sleep(500 * time.Millisecond)
+
+ // Third: cancel during LLM call again
+ h.Chat("Write a very long detailed story about mountain climbing.")
+ time.Sleep(300 * time.Millisecond)
+ h.Cancel()
+ t.Log("Third cancel during LLM")
+ time.Sleep(500 * time.Millisecond)
+
+ // Now resume normally - the conversation should still work
+ h.Chat("Just say 'conversation recovered' and nothing else.")
+ response := h.WaitResponse()
+ t.Logf("Response after multiple cancels: %s", response)
+
+ // Verify the conversation is functional - response should not indicate an error
+ lowerResp := strings.ToLower(response)
+ if strings.Contains(lowerResp, "error") || strings.Contains(lowerResp, "invalid") {
+ t.Errorf("response may indicate an error: %s", response)
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallAndVerifyMessageStructure verifies message structure after LLM cancellation
+func TestClaudeCancelDuringLLMCallAndVerifyMessageStructure(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ h.NewConversation("Write a very long detailed story about a wizard.", "")
+ time.Sleep(300 * time.Millisecond)
+ h.Cancel()
+ time.Sleep(500 * time.Millisecond)
+
+ // Check message structure
+ messages := h.GetMessages()
+ t.Logf("Messages after LLM cancel: %d", len(messages))
+
+ // Should have: system message, user message, cancellation message
+ // The user message should be recorded even if Claude didn't respond
+ userMessageFound := false
+ cancelMessageFound := false
+
+ for _, msg := range messages {
+ t.Logf("Message type: %s", msg.Type)
+ if msg.Type == string(db.MessageTypeUser) {
+ userMessageFound = true
+ }
+ if msg.Type == string(db.MessageTypeAgent) && msg.LlmData != nil {
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "cancelled") {
+ cancelMessageFound = true
+ }
+ }
+ }
+ }
+ }
+
+ if !userMessageFound {
+ t.Error("expected user message to be recorded")
+ }
+ if !cancelMessageFound {
+ t.Error("expected cancellation message to be recorded")
+ }
+
+ // Now send a follow-up and verify no API errors about message format
+ h.Chat("Just say hello.")
+ response := h.WaitResponse()
+ t.Logf("Follow-up response: %s", response)
+
+ // Response should not indicate an error
+ lowerResp := strings.ToLower(response)
+ if strings.Contains(lowerResp, "error") || strings.Contains(lowerResp, "invalid") {
+ t.Errorf("response may indicate API error: %s", response)
+ }
+
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeResumeAfterCancellation tests that a conversation can be resumed after cancellation
+func TestClaudeResumeAfterCancellation(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Start a conversation
+ h.NewConversation("Please run: sleep 5", "")
+
+ // Wait for tool to start
+ h.WaitForAgentWorking()
+ t.Log("Agent started tool call")
+
+ // Cancel
+ h.Cancel()
+ t.Log("Cancelled")
+ time.Sleep(500 * time.Millisecond)
+
+ // Verify cancellation
+ if !h.HasCancellationMessage() {
+ t.Error("expected cancellation message")
+ }
+
+ messagesAfterCancel := len(h.GetMessages())
+ t.Logf("Messages after cancel: %d", messagesAfterCancel)
+
+ // Resume the conversation
+ h.Chat("Hello, let's continue. Please just say 'resumed' and nothing else.")
+
+ // Wait for response
+ response := h.WaitResponse()
+ t.Logf("Response after resume: %s", response)
+
+ // Verify we got more messages
+ messagesAfterResume := len(h.GetMessages())
+ t.Logf("Messages after resume: %d", messagesAfterResume)
+
+ if messagesAfterResume <= messagesAfterCancel {
+ t.Error("expected more messages after resume")
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeTokensMonotonicallyIncreasing tests that token count increases when resuming
+// With prompt caching, total tokens = input + cache_creation + cache_read
+func TestClaudeTokensMonotonicallyIncreasing(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // First conversation turn
+ h.NewConversation("Hello, please respond with 'first response' and nothing else.", "")
+ h.WaitResponse()
+ time.Sleep(500 * time.Millisecond) // Wait for any pending operations
+
+ tokens1 := h.GetRequestTokens()
+ if len(tokens1) == 0 {
+ t.Skip("No token data recorded (API may not be returning it)")
+ }
+ lastToken1 := tokens1[len(tokens1)-1]
+ t.Logf("First turn total tokens: %d", lastToken1)
+
+ // Second conversation turn
+ h.Chat("Now please respond with 'second response' and nothing else.")
+ h.WaitResponse()
+ time.Sleep(500 * time.Millisecond)
+
+ tokens2 := h.GetRequestTokens()
+ if len(tokens2) <= len(tokens1) {
+ t.Fatal("expected more requests in second turn")
+ }
+ lastToken2 := tokens2[len(tokens2)-1]
+ t.Logf("Second turn total tokens: %d", lastToken2)
+
+ // With prompt caching, tokens should increase or stay similar
+ // The key is that we're still sending context (total should be meaningful)
+ if lastToken2 < lastToken1 {
+ t.Errorf("tokens decreased significantly: first=%d, second=%d", lastToken1, lastToken2)
+ }
+
+ // Third turn
+ h.Chat("Third turn - respond with 'third response' only.")
+ h.WaitResponse()
+ time.Sleep(500 * time.Millisecond)
+
+ tokens3 := h.GetRequestTokens()
+ if len(tokens3) <= len(tokens2) {
+ t.Fatal("expected more requests in third turn")
+ }
+ lastToken3 := tokens3[len(tokens3)-1]
+ t.Logf("Third turn total tokens: %d", lastToken3)
+
+ // Each subsequent turn should have at least as many tokens as the first turn
+ // (because we're including more conversation history)
+ if lastToken3 < lastToken1 {
+ t.Errorf("third turn has fewer tokens than first: first=%d, third=%d", lastToken1, lastToken3)
+ }
+
+ t.Logf("Token progression: %d -> %d -> %d", lastToken1, lastToken2, lastToken3)
+}
+
+// TestClaudeResumeAfterCancellationPreservesContext tests context preservation after cancellation
+func TestClaudeResumeAfterCancellationPreservesContext(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Start with specific context
+ h.NewConversation("Remember this secret word: ELEPHANT. I will ask you about it later. For now, just acknowledge with 'understood'.", "")
+ response1 := h.WaitResponse()
+ t.Logf("First response: %s", response1)
+
+ tokens1 := h.GetRequestTokens()
+ if len(tokens1) == 0 {
+ t.Skip("No token data recorded")
+ }
+ t.Logf("Tokens after first exchange: %v", tokens1)
+
+ // Start a slow command to trigger cancellation
+ h.Chat("Run this command: sleep 10")
+ h.WaitForAgentWorking()
+
+ // Cancel
+ h.Cancel()
+ time.Sleep(500 * time.Millisecond)
+
+ tokensAfterCancel := h.GetRequestTokens()
+ t.Logf("Tokens after cancel: %v", tokensAfterCancel)
+
+ // Resume and ask about the secret word
+ h.Chat("What was the secret word I told you to remember?")
+ response2 := h.WaitResponse()
+ t.Logf("Response after resume: %s", response2)
+
+ tokensAfterResume := h.GetRequestTokens()
+ t.Logf("Tokens after resume: %v", tokensAfterResume)
+
+ // Check that the response mentions ELEPHANT
+ if !strings.Contains(strings.ToUpper(response2), "ELEPHANT") {
+ t.Errorf("expected response to mention ELEPHANT, got: %s", response2)
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeMultipleCancellations tests multiple cancellations in a row
+func TestClaudeMultipleCancellations(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // First cancellation
+ h.NewConversation("Run: sleep 10", "")
+ h.WaitForAgentWorking()
+ h.Cancel()
+ time.Sleep(300 * time.Millisecond)
+
+ if !h.HasCancellationMessage() {
+ t.Error("expected first cancellation message")
+ }
+
+ // Second cancellation
+ h.Chat("Run: sleep 10")
+ time.Sleep(2 * time.Second) // Wait for Claude to respond and start tool
+ h.Cancel()
+ time.Sleep(300 * time.Millisecond)
+
+ // Third: complete normally
+ h.Chat("Just say 'done' and nothing else.")
+ response := h.WaitResponse()
+ t.Logf("Final response: %s", response)
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelImmediately tests cancelling immediately after sending a message
+func TestClaudeCancelImmediately(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ h.NewConversation("Write a very long essay about everything.", "")
+
+ // Cancel immediately
+ time.Sleep(50 * time.Millisecond)
+ h.Cancel()
+
+ time.Sleep(500 * time.Millisecond)
+
+ // Should still be able to resume
+ h.Chat("Just say 'hello'")
+ response := h.WaitResponse()
+ t.Logf("Response after immediate cancel: %s", response)
+
+ if response == "" {
+ t.Error("expected a response after resuming from immediate cancel")
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelWithPendingToolResult tests that missing tool results are handled properly
+func TestClaudeCancelWithPendingToolResult(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // This tests the insertMissingToolResults logic
+ h.NewConversation("Run: sleep 20", "")
+ h.WaitForAgentWorking()
+
+ // Cancel during tool execution
+ h.Cancel()
+ time.Sleep(500 * time.Millisecond)
+
+ // Resume - this should handle the missing tool result
+ h.Chat("Please just say 'recovered' if you can hear me.")
+ response := h.WaitResponse()
+ t.Logf("Recovery response: %s", response)
+
+ // The conversation should have recovered
+ // Claude should not complain about bad messages
+ if strings.Contains(strings.ToLower(response), "error") {
+ t.Errorf("response indicates an error, which may mean message handling failed: %s", response)
+ }
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallRapidFire tests rapid cancellations during LLM calls
+func TestClaudeCancelDuringLLMCallRapidFire(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Send message and cancel as fast as possible, multiple times
+ for i := 0; i < 3; i++ {
+ if i == 0 {
+ h.NewConversation("Write a long story.", "")
+ } else {
+ h.Chat("Write another long story.")
+ }
+ time.Sleep(100 * time.Millisecond)
+ h.Cancel()
+ time.Sleep(200 * time.Millisecond)
+ t.Logf("Rapid cancel %d complete", i+1)
+ }
+
+ // Now do a normal conversation
+ h.Chat("Just say 'stable' and nothing else.")
+ response := h.WaitResponse()
+ t.Logf("Final response after rapid cancels: %s", response)
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallWithToolUseResponse tests cancel when Claude is about to use a tool
+func TestClaudeCancelDuringLLMCallWithToolUseResponse(t *testing.T) {
+ h := NewClaudeTestHarness(t)
+ defer h.Close()
+
+ // Ask Claude to use a tool - the response will contain tool_use
+ // Cancel before the tool actually executes
+ h.NewConversation("Run: echo hello world", "")
+
+ // Wait just enough for the LLM request to be sent but not for tool execution
+ time.Sleep(500 * time.Millisecond)
+
+ // Cancel - this might catch the LLM responding with tool_use but before tool execution
+ h.Cancel()
+ time.Sleep(500 * time.Millisecond)
+
+ t.Logf("Cancelled during potential tool_use response")
+
+ // Resume and verify conversation works
+ h.Chat("Just say 'ok' if you can hear me.")
+ response := h.WaitResponse()
+ t.Logf("Response: %s", response)
+
+ // Verify tokens are maintained
+ h.VerifyTokensNonDecreasing()
+}
@@ -0,0 +1,376 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// setupTestDB creates a test database
+func setupTestDB(t *testing.T) (*db.DB, func()) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ database, err := db.New(db.Config{DSN: tmpDir + "/test.db"})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate test database: %v", err)
+ }
+
+ return database, func() {
+ database.Close()
+ }
+}
+
+// TestCancelWithPredictableModel tests cancellation with the predictable model
+func TestCancelWithPredictableModel(t *testing.T) {
+ // Create test database
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+
+ // Register the bash tool so the sleep command actually runs and can be cancelled
+ toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+ server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Start a conversation with a message that triggers a slow bash command
+ chatReq := ChatRequest{
+ Message: "bash: sleep 5",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait for the tool to start executing
+ time.Sleep(300 * time.Millisecond)
+
+ // Verify agent is working
+ var messages []generated.Message
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages: %v", err)
+ }
+
+ // Should have user message and assistant message with tool use
+ if len(messages) < 2 {
+ t.Fatalf("expected at least 2 messages, got %d", len(messages))
+ }
+
+ // Cancel the conversation
+ cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+ cancelW := httptest.NewRecorder()
+
+ server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+ if cancelW.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+ }
+
+ var cancelResp map[string]string
+ if err := json.Unmarshal(cancelW.Body.Bytes(), &cancelResp); err != nil {
+ t.Fatalf("failed to parse cancel response: %v", err)
+ }
+
+ if cancelResp["status"] != "cancelled" {
+ t.Errorf("expected status 'cancelled', got '%s'", cancelResp["status"])
+ }
+
+ // Wait for cancellation to complete and cancelled message to be recorded
+ time.Sleep(300 * time.Millisecond)
+
+ // Verify that a cancelled tool result was recorded
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages after cancel: %v", err)
+ }
+
+ // Should have: user message, assistant message with tool use, cancelled tool result, and end turn message
+ if len(messages) < 4 {
+ t.Fatalf("expected at least 4 messages after cancel, got %d", len(messages))
+ }
+
+ // Check that we have the cancelled tool result
+ foundCancelledResult := false
+ foundEndTurnMessage := false
+ for i := len(messages) - 1; i >= 0; i-- {
+ msg := messages[i]
+ if msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ // Check for cancelled tool result
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult && content.ToolError {
+ for _, result := range content.ToolResult {
+ if result.Type == llm.ContentTypeText && strings.Contains(result.Text, "cancelled") {
+ foundCancelledResult = true
+ break
+ }
+ }
+ }
+ }
+
+ // Check for end turn message
+ if msg.Type == string(db.MessageTypeAgent) && llmMsg.EndOfTurn {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "Operation cancelled") {
+ foundEndTurnMessage = true
+ break
+ }
+ }
+ }
+ }
+
+ if !foundCancelledResult {
+ t.Error("expected to find cancelled tool result in conversation")
+ }
+
+ if !foundEndTurnMessage {
+ t.Error("expected to find end turn message after cancellation")
+ }
+
+ // Test that conversation can be resumed after cancellation
+ resumeReq := ChatRequest{
+ Message: "echo: test after cancel",
+ Model: "predictable",
+ }
+ resumeBody, _ := json.Marshal(resumeReq)
+
+ resumeChatReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+ resumeChatReq.Header.Set("Content-Type", "application/json")
+ resumeW := httptest.NewRecorder()
+
+ server.handleChatConversation(resumeW, resumeChatReq, conversationID)
+
+ if resumeW.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202 for resume, got %d: %s", resumeW.Code, resumeW.Body.String())
+ }
+
+ // Wait for the response
+ time.Sleep(300 * time.Millisecond)
+
+ // Verify conversation continued
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages after resume: %v", err)
+ }
+
+ // Should have additional messages from the resumed conversation
+ if len(messages) < 5 {
+ t.Fatalf("expected at least 5 messages after resume, got %d", len(messages))
+ }
+
+ // Check that we got the expected response
+ foundContinueResponse := false
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeAgent) {
+ continue
+ }
+ if msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "test after cancel") {
+ foundContinueResponse = true
+ break
+ }
+ }
+ }
+
+ if !foundContinueResponse {
+ t.Error("expected to find 'test after cancel' response")
+ }
+}
+
+// TestCancelWithNoActiveConversation tests cancelling when there's no active conversation
+func TestCancelWithNoActiveConversation(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create a conversation but don't start it
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Try to cancel without any active loop
+ cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+ cancelW := httptest.NewRecorder()
+
+ server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+ if cancelW.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+ }
+
+ var cancelResp map[string]string
+ if err := json.Unmarshal(cancelW.Body.Bytes(), &cancelResp); err != nil {
+ t.Fatalf("failed to parse cancel response: %v", err)
+ }
+
+ if cancelResp["status"] != "no_active_conversation" {
+ t.Errorf("expected status 'no_active_conversation', got '%s'", cancelResp["status"])
+ }
+}
+
+// TestCancelDuringTextGeneration tests cancelling during text generation (no tool call)
+func TestCancelDuringTextGeneration(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Use delay: prefix to trigger slow response
+ predictableService := loop.NewPredictableService()
+
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Start conversation with a delay to simulate slow text generation
+ chatReq := ChatRequest{
+ Message: "delay: 2",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait briefly for processing to start
+ time.Sleep(100 * time.Millisecond)
+
+ // Cancel during text generation
+ cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+ cancelW := httptest.NewRecorder()
+
+ server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+ if cancelW.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+ }
+
+ // Wait for cancellation
+ time.Sleep(200 * time.Millisecond)
+
+ // Verify that no cancelled tool result was added (since there was no tool call)
+ var messages []generated.Message
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages: %v", err)
+ }
+
+ // Should only have user message (and possibly incomplete assistant message)
+ // Should NOT have a tool result message
+ for _, msg := range messages {
+ if msg.Type == string(db.MessageTypeUser) {
+ if msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ t.Error("did not expect tool result when cancelling during text generation")
+ }
+ }
+ }
+ }
+}
+
+// testLLMManager is a simple test implementation of LLMProvider
+type testLLMManager struct {
+ service llm.Service
+}
+
+func (m *testLLMManager) GetService(modelID string) (llm.Service, error) {
+ return m.service, nil
+}
+
+func (m *testLLMManager) GetAvailableModels() []string {
+ return []string{"predictable"}
+}
+
+func (m *testLLMManager) HasModel(modelID string) bool {
+ return modelID == "predictable"
+}
@@ -0,0 +1,163 @@
+package server
+
+import (
+ "encoding/json"
+ "testing"
+
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+)
+
+// TestContextWindowSizeCalculation tests that the context window size is correctly
+// calculated including cached tokens.
+func TestContextWindowSizeCalculation(t *testing.T) {
+ // Test the calculateContextWindowSize function directly
+ t.Run("includes_all_token_types", func(t *testing.T) {
+ // Create usage data with all token types
+ usage := llm.Usage{
+ InputTokens: 100,
+ CacheCreationInputTokens: 50,
+ CacheReadInputTokens: 200,
+ OutputTokens: 30,
+ }
+ usageJSON, _ := json.Marshal(usage)
+ usageStr := string(usageJSON)
+
+ messages := []APIMessage{
+ {
+ Type: string(db.MessageTypeAgent),
+ UsageData: &usageStr,
+ },
+ }
+
+ // Expected: 100 + 50 + 200 + 30 = 380
+ got := calculateContextWindowSize(messages)
+ want := uint64(380)
+
+ if got != want {
+ t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+ }
+ })
+
+ t.Run("only_input_tokens", func(t *testing.T) {
+ // Test with just input tokens (no caching)
+ usage := llm.Usage{
+ InputTokens: 150,
+ OutputTokens: 50,
+ }
+ usageJSON, _ := json.Marshal(usage)
+ usageStr := string(usageJSON)
+
+ messages := []APIMessage{
+ {
+ Type: string(db.MessageTypeAgent),
+ UsageData: &usageStr,
+ },
+ }
+
+ // Expected: 150 + 50 = 200
+ got := calculateContextWindowSize(messages)
+ want := uint64(200)
+
+ if got != want {
+ t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+ }
+ })
+
+ t.Run("uses_last_message_with_usage", func(t *testing.T) {
+ // Test that we use the last message, not the first
+ usage1 := llm.Usage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ }
+ usage1JSON, _ := json.Marshal(usage1)
+ usage1Str := string(usage1JSON)
+
+ usage2 := llm.Usage{
+ InputTokens: 200,
+ CacheReadInputTokens: 100,
+ OutputTokens: 75,
+ }
+ usage2JSON, _ := json.Marshal(usage2)
+ usage2Str := string(usage2JSON)
+
+ messages := []APIMessage{
+ {
+ Type: string(db.MessageTypeAgent),
+ UsageData: &usage1Str,
+ },
+ {
+ Type: string(db.MessageTypeUser),
+ UsageData: nil, // User messages typically don't have usage
+ },
+ {
+ Type: string(db.MessageTypeAgent),
+ UsageData: &usage2Str,
+ },
+ }
+
+ // Expected: 200 + 100 + 75 = 375 (from the last message)
+ got := calculateContextWindowSize(messages)
+ want := uint64(375)
+
+ if got != want {
+ t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+ }
+ })
+
+ t.Run("empty_messages", func(t *testing.T) {
+ messages := []APIMessage{}
+ got := calculateContextWindowSize(messages)
+ want := uint64(0)
+
+ if got != want {
+ t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+ }
+ })
+}
+
+// TestContextWindowGrowsWithConversation tests that the context window size grows
+// as the conversation progresses, using the test harness and predictable service.
+func TestContextWindowGrowsWithConversation(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.Close()
+
+ // Start a new conversation
+ h.NewConversation("echo: first message", "/tmp")
+
+ // Wait for the response
+ resp1 := h.WaitResponse()
+ t.Logf("First response: %q", resp1)
+
+ // Get the context window size from the first message
+ firstSize := h.GetContextWindowSize()
+ t.Logf("First context window size: %d", firstSize)
+ if firstSize == 0 {
+ t.Fatal("expected non-zero context window size after first message")
+ }
+
+ // Send another message
+ h.Chat("echo: second message that is longer")
+ resp2 := h.WaitResponse()
+ t.Logf("Second response: %q", resp2)
+
+ // Context window should have grown
+ secondSize := h.GetContextWindowSize()
+ t.Logf("Second context window size: %d", secondSize)
+ if secondSize <= firstSize {
+ t.Errorf("context window should grow: first=%d, second=%d", firstSize, secondSize)
+ }
+
+ // Send a third message
+ h.Chat("echo: third message with even more text to demonstrate growth")
+ resp3 := h.WaitResponse()
+ t.Logf("Third response: %q", resp3)
+
+ thirdSize := h.GetContextWindowSize()
+ t.Logf("Third context window size: %d", thirdSize)
+ if thirdSize <= secondSize {
+ t.Errorf("context window should grow: second=%d, third=%d", secondSize, thirdSize)
+ }
+
+ t.Logf("Context window sizes: first=%d, second=%d, third=%d", firstSize, secondSize, thirdSize)
+}
@@ -0,0 +1,92 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "shelley.exe.dev/db/generated"
+)
+
+func TestGetConversationBySlug(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.Close()
+
+ // Create a conversation with a slug
+ slug := "my-test-slug"
+ conv, err := h.db.CreateConversation(t.Context(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+
+ // Test successful lookup
+ req := httptest.NewRequest("GET", "/api/conversation-by-slug/"+slug, nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var result generated.Conversation
+ if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ if result.ConversationID != conv.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, result.ConversationID)
+ }
+
+ // Test non-existent slug
+ req = httptest.NewRequest("GET", "/api/conversation-by-slug/non-existent-slug", nil)
+ rec = httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("Expected status 404, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ // Test empty slug
+ req = httptest.NewRequest("GET", "/api/conversation-by-slug/", nil)
+ rec = httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("Expected status 400, got %d: %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestIsConversationSlugPath(t *testing.T) {
+ tests := []struct {
+ path string
+ expect bool
+ }{
+ // Should NOT be treated as slugs
+ {"/", false},
+ {"/api/conversations", false},
+ {"/api/conversation/abc", false},
+ {"/debug/llm", false},
+ {"/main.js", false},
+ {"/styles.css", false},
+ {"/index.html", false},
+ {"/version", false},
+ {"/my-conversation", false}, // not in /c/ namespace
+ {"/hello-world", false},
+ // Should be treated as slugs (must be under /c/)
+ {"/c/my-conversation", true},
+ {"/c/hello-world", true},
+ {"/c/fix-the-bug", true},
+ {"/c/c123abc", true},
+ }
+
+ for _, tt := range tests {
+ got := isConversationSlugPath(tt.path)
+ if got != tt.expect {
+ t.Errorf("isConversationSlugPath(%q) = %v, want %v", tt.path, got, tt.expect)
+ }
+ }
+}
@@ -0,0 +1,292 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// TestMessageQueuedDuringThinking tests that messages sent while the LLM is
+// processing (thinking/tool execution) are properly queued and eventually processed.
+func TestMessageQueuedDuringThinking(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Send first message that triggers a slow response via "delay:" prefix
+ chatReq := ChatRequest{
+ Message: "delay: 2",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202 for first message, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait for the LLM to start processing (but still be in the delay)
+ time.Sleep(200 * time.Millisecond)
+
+ // Now send a SECOND message while the first is still processing
+ // This is the bug: this message should be immediately recorded and visible,
+ // not lost until the first message finishes processing
+ secondReq := ChatRequest{
+ Message: "echo: second message while thinking",
+ Model: "predictable",
+ }
+ secondBody, _ := json.Marshal(secondReq)
+
+ req2 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(secondBody)))
+ req2.Header.Set("Content-Type", "application/json")
+ w2 := httptest.NewRecorder()
+
+ server.handleChatConversation(w2, req2, conversationID)
+ if w2.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202 for second message, got %d: %s", w2.Code, w2.Body.String())
+ }
+
+ // The second message should be recorded in the database IMMEDIATELY
+ // (or at least very soon), not waiting for the first message to finish
+ // Wait a short time for the message to be recorded
+ time.Sleep(100 * time.Millisecond)
+
+ var messages []generated.Message
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages: %v", err)
+ }
+
+ // Look for the second user message in the database
+ foundSecondUserMessage := false
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeUser) {
+ continue
+ }
+ if msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "second message while thinking") {
+ foundSecondUserMessage = true
+ break
+ }
+ }
+ }
+
+ if !foundSecondUserMessage {
+ t.Error("BUG: second user message sent during LLM processing was not immediately recorded to database")
+ t.Logf("Found %d messages total:", len(messages))
+ for i, msg := range messages {
+ t.Logf(" Message %d: type=%s", i, msg.Type)
+ }
+ }
+
+ // Wait for everything to complete
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages: %v", err)
+ }
+ // Look for response to second message
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "second message while thinking") {
+ // Found the response
+ return
+ }
+ }
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Error("timed out waiting for response to second message")
+}
+
+// TestContextPreservedAfterCancel tests that conversation context is properly
+// preserved after cancellation and the conversation can be resumed correctly.
+func TestContextPreservedAfterCancel(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Send first message and let it complete
+ chatReq := ChatRequest{
+ Message: "echo: initial context message",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait for first message to complete
+ time.Sleep(300 * time.Millisecond)
+
+ // Now start a slow operation and cancel it
+ slowReq := ChatRequest{
+ Message: "bash: sleep 5",
+ Model: "predictable",
+ }
+ slowBody, _ := json.Marshal(slowReq)
+
+ req2 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(slowBody)))
+ req2.Header.Set("Content-Type", "application/json")
+ w2 := httptest.NewRecorder()
+
+ server.handleChatConversation(w2, req2, conversationID)
+ if w2.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w2.Code, w2.Body.String())
+ }
+
+ // Wait for tool to start
+ time.Sleep(200 * time.Millisecond)
+
+ // Cancel the conversation
+ cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+ cancelW := httptest.NewRecorder()
+ server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+ if cancelW.Code != http.StatusOK {
+ t.Fatalf("expected cancel status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+ }
+
+ // Wait for cancellation to complete
+ time.Sleep(200 * time.Millisecond)
+
+ // Clear the predictable service request history so we can inspect the next request
+ predictableService.ClearRequests()
+
+ // Resume the conversation
+ resumeReq := ChatRequest{
+ Message: "echo: after cancel",
+ Model: "predictable",
+ }
+ resumeBody, _ := json.Marshal(resumeReq)
+
+ req3 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+ req3.Header.Set("Content-Type", "application/json")
+ w3 := httptest.NewRecorder()
+
+ server.handleChatConversation(w3, req3, conversationID)
+ if w3.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202 for resume, got %d: %s", w3.Code, w3.Body.String())
+ }
+
+ // Wait for the request to be processed
+ time.Sleep(300 * time.Millisecond)
+
+ // Check that the LLM request included the conversation history
+ lastReq := predictableService.GetLastRequest()
+ if lastReq == nil {
+ t.Fatal("BUG: no LLM request was made after resume")
+ }
+
+ // The request should include ALL previous messages:
+ // 1. Initial context message (user)
+ // 2. Response to initial context (assistant)
+ // 3. bash: sleep 5 (user)
+ // 4. Assistant response with tool use
+ // 5. Cancelled tool result (user)
+ // 6. [Operation cancelled] (assistant)
+ // 7. echo: after cancel (user)
+ //
+ // If context is lost, we'll only have the last message (#7)
+
+ if len(lastReq.Messages) < 3 {
+ t.Errorf("BUG: context lost after cancellation! Expected at least 3 messages in LLM request, got %d", len(lastReq.Messages))
+ t.Log("Messages in request:")
+ for i, msg := range lastReq.Messages {
+ t.Logf(" Message %d: role=%s, content_count=%d", i, msg.Role, len(msg.Content))
+ for j, content := range msg.Content {
+ if content.Type == llm.ContentTypeText {
+ // Truncate long text
+ text := content.Text
+ if len(text) > 100 {
+ text = text[:100] + "..."
+ }
+ t.Logf(" Content %d: type=%s, text=%q", j, content.Type, text)
+ } else {
+ t.Logf(" Content %d: type=%s", j, content.Type)
+ }
+ }
+ }
+ }
+
+ // Check that "initial context message" appears somewhere in the history
+ foundInitialContext := false
+ for _, msg := range lastReq.Messages {
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "initial context message") {
+ foundInitialContext = true
+ break
+ }
+ }
+ }
+
+ if !foundInitialContext {
+ t.Error("BUG: initial context message was not preserved after cancellation")
+ }
+}
@@ -0,0 +1,483 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+ "shelley.exe.dev/subpub"
+)
+
+var errConversationModelMismatch = errors.New("conversation model mismatch")
+
+// ConversationManager manages a single active conversation
+type ConversationManager struct {
+ conversationID string
+ db *db.DB
+ loop *loop.Loop
+ loopCancel context.CancelFunc
+ loopCtx context.Context
+ mu sync.Mutex
+ lastActivity time.Time
+ modelID string
+ history []llm.Message
+ system []llm.SystemContent
+ recordMessage loop.MessageRecordFunc
+ logger *slog.Logger
+ toolSetConfig claudetool.ToolSetConfig
+ toolSet *claudetool.ToolSet // created per-conversation when loop starts
+
+ subpub *subpub.SubPub[StreamResponse]
+
+ hydrated bool
+ hasConversationEvents bool
+ cwd string // working directory for tools
+}
+
+// NewConversationManager constructs a manager with dependencies but defers hydration until needed.
+func NewConversationManager(conversationID string, database *db.DB, baseLogger *slog.Logger, toolSetConfig claudetool.ToolSetConfig, recordMessage loop.MessageRecordFunc) *ConversationManager {
+ logger := baseLogger
+ if logger == nil {
+ logger = slog.Default()
+ }
+ logger = logger.With("conversationID", conversationID)
+
+ return &ConversationManager{
+ conversationID: conversationID,
+ db: database,
+ lastActivity: time.Now(),
+ recordMessage: recordMessage,
+ logger: logger,
+ toolSetConfig: toolSetConfig,
+ subpub: subpub.New[StreamResponse](),
+ }
+}
+
+// Hydrate loads conversation state from the database, generating a system prompt if missing.
+func (cm *ConversationManager) Hydrate(ctx context.Context) error {
+ cm.mu.Lock()
+ if cm.hydrated {
+ cm.lastActivity = time.Now()
+ cm.mu.Unlock()
+ return nil
+ }
+ cm.mu.Unlock()
+
+ conversation, err := cm.db.GetConversationByID(ctx, cm.conversationID)
+ if err != nil {
+ return fmt.Errorf("conversation not found: %w", err)
+ }
+
+ var messages []generated.Message
+ err = cm.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ messages, err = q.ListMessages(ctx, cm.conversationID)
+ return err
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get conversation history: %w", err)
+ }
+
+ if conversation.UserInitiated && !hasSystemMessage(messages) {
+ systemMsg, err := cm.createSystemPrompt(ctx)
+ if err != nil {
+ return err
+ }
+ if systemMsg != nil {
+ messages = append(messages, *systemMsg)
+ }
+ }
+
+ history, system := cm.partitionMessages(messages)
+
+ // Load cwd from conversation if available
+ cwd := ""
+ if conversation.Cwd != nil {
+ cwd = *conversation.Cwd
+ }
+
+ cm.mu.Lock()
+ cm.history = history
+ cm.system = system
+ cm.hasConversationEvents = len(history) > 0
+ cm.lastActivity = time.Now()
+ cm.hydrated = true
+ cm.cwd = cwd
+ cm.mu.Unlock()
+
+ cm.logSystemPromptState(system, len(messages))
+
+ return nil
+}
+
+// AcceptUserMessage enqueues a user message, ensuring the loop is ready first.
+// The message is recorded to the database immediately so it appears in the UI,
+// even if the loop is busy processing a previous request.
+func (cm *ConversationManager) AcceptUserMessage(ctx context.Context, service llm.Service, modelID string, message llm.Message) (bool, error) {
+ if service == nil {
+ return false, fmt.Errorf("llm service is required")
+ }
+
+ if err := cm.Hydrate(ctx); err != nil {
+ return false, err
+ }
+
+ if err := cm.ensureLoop(service, modelID); err != nil {
+ return false, err
+ }
+
+ cm.mu.Lock()
+ isFirst := !cm.hasConversationEvents
+ cm.hasConversationEvents = true
+ loopInstance := cm.loop
+ cm.lastActivity = time.Now()
+ recordMessage := cm.recordMessage
+ cm.mu.Unlock()
+
+ if loopInstance == nil {
+ return false, fmt.Errorf("conversation loop not initialized")
+ }
+
+ // Record the user message to the database immediately so it appears in the UI,
+ // even if the loop is busy processing a previous request
+ if recordMessage != nil {
+ if err := recordMessage(ctx, message, llm.Usage{}); err != nil {
+ cm.logger.Error("failed to record user message immediately", "error", err)
+ // Continue anyway - the loop will also try to record it
+ }
+ }
+
+ loopInstance.QueueUserMessage(message)
+
+ return isFirst, nil
+}
+
+// Touch updates last activity timestamp.
+func (cm *ConversationManager) Touch() {
+ cm.mu.Lock()
+ cm.lastActivity = time.Now()
+ cm.mu.Unlock()
+}
+
+func hasSystemMessage(messages []generated.Message) bool {
+ for _, msg := range messages {
+ if msg.Type == string(db.MessageTypeSystem) {
+ return true
+ }
+ }
+ return false
+}
+
+func (cm *ConversationManager) createSystemPrompt(ctx context.Context) (*generated.Message, error) {
+ systemPrompt, err := GenerateSystemPrompt(cm.cwd)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate system prompt: %w", err)
+ }
+
+ if systemPrompt == "" {
+ cm.logger.Info("Skipping empty system prompt generation")
+ return nil, nil
+ }
+
+ systemMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: systemPrompt}},
+ }
+
+ created, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: cm.conversationID,
+ Type: db.MessageTypeSystem,
+ LLMData: systemMessage,
+ UsageData: llm.Usage{},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to store system prompt: %w", err)
+ }
+
+ if err := cm.db.QueriesTx(ctx, func(q *generated.Queries) error {
+ return q.UpdateConversationTimestamp(ctx, cm.conversationID)
+ }); err != nil {
+ cm.logger.Warn("Failed to update conversation timestamp after system prompt", "error", err)
+ }
+
+ cm.logger.Info("Stored system prompt", "length", len(systemPrompt))
+ return created, nil
+}
+
+func (cm *ConversationManager) partitionMessages(messages []generated.Message) ([]llm.Message, []llm.SystemContent) {
+ var history []llm.Message
+ var system []llm.SystemContent
+
+ for _, msg := range messages {
+ llmMsg, err := convertToLLMMessage(msg)
+ if err != nil {
+ cm.logger.Warn("Failed to convert message to LLM format", "messageID", msg.MessageID, "error", err)
+ continue
+ }
+
+ if msg.Type == string(db.MessageTypeSystem) {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && content.Text != "" {
+ system = append(system, llm.SystemContent{Type: "text", Text: content.Text})
+ }
+ }
+ continue
+ }
+
+ history = append(history, llmMsg)
+ }
+
+ return history, system
+}
+
+func (cm *ConversationManager) logSystemPromptState(system []llm.SystemContent, messageCount int) {
+ if len(system) == 0 {
+ cm.logger.Warn("No system prompt found in database", "message_count", messageCount)
+ return
+ }
+
+ length := 0
+ for _, sys := range system {
+ length += len(sys.Text)
+ }
+ cm.logger.Info("Loaded system prompt from database", "system_items", len(system), "total_length", length)
+}
+
+func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) error {
+ cm.mu.Lock()
+ if cm.loop != nil {
+ existingModel := cm.modelID
+ cm.mu.Unlock()
+ if existingModel != "" && modelID != "" && existingModel != modelID {
+ return fmt.Errorf("%w: conversation already uses model %s; requested %s", errConversationModelMismatch, existingModel, modelID)
+ }
+ return nil
+ }
+
+ history := append([]llm.Message(nil), cm.history...)
+ system := append([]llm.SystemContent(nil), cm.system...)
+ recordMessage := cm.recordMessage
+ logger := cm.logger
+ cwd := cm.cwd
+ toolSetConfig := cm.toolSetConfig
+ conversationID := cm.conversationID
+ db := cm.db
+ cm.mu.Unlock()
+
+ // Create tools for this conversation with the conversation's working directory
+ toolSetConfig.WorkingDir = cwd
+ toolSetConfig.ModelID = modelID
+ toolSetConfig.OnWorkingDirChange = func(newDir string) {
+ // Persist working directory change to database
+ if err := db.UpdateConversationCwd(context.Background(), conversationID, newDir); err != nil {
+ logger.Error("failed to persist working directory change", "error", err, "newDir", newDir)
+ }
+ }
+
+ processCtx, cancel := context.WithTimeout(context.Background(), 12*time.Hour)
+ toolSet := claudetool.NewToolSet(processCtx, toolSetConfig)
+
+ loopInstance := loop.NewLoop(loop.Config{
+ LLM: service,
+ History: history,
+ Tools: toolSet.Tools(),
+ RecordMessage: recordMessage,
+ Logger: logger,
+ System: system,
+ WorkingDir: cwd,
+ })
+
+ cm.mu.Lock()
+ if cm.loop != nil {
+ cm.mu.Unlock()
+ cancel()
+ toolSet.Cleanup()
+ existingModel := cm.modelID
+ if existingModel != "" && modelID != "" && existingModel != modelID {
+ return fmt.Errorf("%w: conversation already uses model %s; requested %s", errConversationModelMismatch, existingModel, modelID)
+ }
+ return nil
+ }
+ cm.loop = loopInstance
+ cm.loopCancel = cancel
+ cm.loopCtx = processCtx
+ cm.modelID = modelID
+ cm.toolSet = toolSet
+ cm.history = nil
+ cm.system = nil
+ cm.mu.Unlock()
+
+ go func() {
+ if err := loopInstance.Go(processCtx); err != nil && err != context.DeadlineExceeded && err != context.Canceled {
+ if logger != nil {
+ logger.Error("Conversation loop stopped", "error", err)
+ } else {
+ slog.Default().Error("Conversation loop stopped", "error", err)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (cm *ConversationManager) stopLoop() {
+ cm.mu.Lock()
+ cancel := cm.loopCancel
+ toolSet := cm.toolSet
+ cm.loopCancel = nil
+ cm.loopCtx = nil
+ cm.loop = nil
+ cm.modelID = ""
+ cm.toolSet = nil
+ cm.mu.Unlock()
+
+ if cancel != nil {
+ cancel()
+ }
+ if toolSet != nil {
+ toolSet.Cleanup()
+ }
+}
+
+// CancelConversation cancels the current conversation loop and records a cancelled tool result if a tool was in progress
+func (cm *ConversationManager) CancelConversation(ctx context.Context) error {
+ cm.mu.Lock()
+ loopInstance := cm.loop
+ loopCtx := cm.loopCtx
+ cancel := cm.loopCancel
+ cm.mu.Unlock()
+
+ if loopInstance == nil {
+ cm.logger.Info("No active loop to cancel")
+ return nil
+ }
+
+ cm.logger.Info("Cancelling conversation")
+
+ // Check if there's an in-progress tool call by examining the history
+ history := loopInstance.GetHistory()
+ var inProgressToolID string
+ var inProgressToolName string
+
+ // Find tool_uses that don't have corresponding tool_results.
+ // Strategy:
+ // 1. Find the last assistant message that contains tool_uses
+ // 2. Collect all tool_result IDs from user messages AFTER that assistant message
+ // 3. Find tool_uses that don't have matching results
+
+ // Step 1: Find the index of the last assistant message with tool_uses
+ lastToolUseAssistantIdx := -1
+ for i := len(history) - 1; i >= 0; i-- {
+ msg := history[i]
+ if msg.Role == llm.MessageRoleAssistant {
+ hasToolUse := false
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ hasToolUse = true
+ break
+ }
+ }
+ if hasToolUse {
+ lastToolUseAssistantIdx = i
+ break
+ }
+ }
+ }
+
+ if lastToolUseAssistantIdx >= 0 {
+ // Step 2: Collect all tool_result IDs from messages after the assistant message
+ toolResultIDs := make(map[string]bool)
+ for i := lastToolUseAssistantIdx + 1; i < len(history); i++ {
+ msg := history[i]
+ if msg.Role == llm.MessageRoleUser {
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ toolResultIDs[content.ToolUseID] = true
+ }
+ }
+ }
+ }
+
+ // Step 3: Find the first tool_use that doesn't have a result
+ assistantMsg := history[lastToolUseAssistantIdx]
+ for _, content := range assistantMsg.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ if !toolResultIDs[content.ID] {
+ inProgressToolID = content.ID
+ inProgressToolName = content.ToolName
+ break
+ }
+ }
+ }
+ }
+
+ // Cancel the context
+ if cancel != nil {
+ cancel()
+ }
+
+ // Wait briefly for the loop to stop
+ if loopCtx != nil {
+ select {
+ case <-loopCtx.Done():
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+
+ // Record cancellation messages
+ if inProgressToolID != "" {
+ // If there was an in-progress tool, record a cancelled result
+ cm.logger.Info("Recording cancelled tool result", "tool_id", inProgressToolID, "tool_name", inProgressToolName)
+ cancelTime := time.Now()
+ cancelledMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: inProgressToolID,
+ ToolError: true,
+ ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "Tool execution cancelled by user"}},
+ ToolUseStartTime: &cancelTime,
+ ToolUseEndTime: &cancelTime,
+ },
+ },
+ }
+
+ if err := cm.recordMessage(ctx, cancelledMessage, llm.Usage{}); err != nil {
+ cm.logger.Error("Failed to record cancelled tool result", "error", err)
+ return fmt.Errorf("failed to record cancelled tool result: %w", err)
+ }
+ }
+
+ // Always record an assistant message with EndOfTurn to properly end the turn
+ // This ensures agentWorking() returns false, even if no tool was executing
+ endTurnMessage := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "[Operation cancelled]"}},
+ EndOfTurn: true,
+ }
+
+ if err := cm.recordMessage(ctx, endTurnMessage, llm.Usage{}); err != nil {
+ cm.logger.Error("Failed to record end turn message", "error", err)
+ return fmt.Errorf("failed to record end turn message: %w", err)
+ }
+
+ cm.mu.Lock()
+ cm.loopCancel = nil
+ cm.loopCtx = nil
+ cm.loop = nil
+ cm.modelID = ""
+ // Reset hydrated so that the next AcceptUserMessage will reload history from the database
+ cm.hydrated = false
+ cm.mu.Unlock()
+
+ return nil
+}
@@ -0,0 +1,296 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestWorkingDirectoryConfiguration tests that the working directory (cwd) setting
+// is properly passed through from HTTP requests to tool execution.
+func TestWorkingDirectoryConfiguration(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.Close()
+
+ t.Run("cwd_tmp", func(t *testing.T) {
+ h.NewConversation("bash: pwd", "/tmp")
+ result := strings.TrimSpace(h.WaitToolResult())
+ // Resolve symlinks for comparison (on macOS, /tmp -> /private/tmp)
+ expected, _ := filepath.EvalSymlinks("/tmp")
+ if result != expected {
+ t.Errorf("expected %q, got: %s", expected, result)
+ }
+ })
+
+ t.Run("cwd_root", func(t *testing.T) {
+ h.NewConversation("bash: pwd", "/")
+ result := strings.TrimSpace(h.WaitToolResult())
+ if result != "/" {
+ t.Errorf("expected '/', got: %s", result)
+ }
+ })
+}
+
+// TestListDirectory tests the list-directory API endpoint used by the directory picker.
+func TestListDirectory(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.Close()
+
+ t.Run("list_tmp", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/api/list-directory?path=/tmp", nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ if resp.Path != "/tmp" {
+ t.Errorf("expected path '/tmp', got: %s", resp.Path)
+ }
+
+ if resp.Parent != "/" {
+ t.Errorf("expected parent '/', got: %s", resp.Parent)
+ }
+ })
+
+ t.Run("list_root", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/api/list-directory?path=/", nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ if resp.Path != "/" {
+ t.Errorf("expected path '/', got: %s", resp.Path)
+ }
+
+ // Root should have no parent
+ if resp.Parent != "" {
+ t.Errorf("expected no parent, got: %s", resp.Parent)
+ }
+
+ // Root should have at least some directories (tmp, etc, home, etc.)
+ if len(resp.Entries) == 0 {
+ t.Error("expected at least some entries in root")
+ }
+ })
+
+ t.Run("list_default_path", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/api/list-directory", nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ // Should default to home directory
+ homeDir, _ := os.UserHomeDir()
+ if homeDir != "" && resp.Path != homeDir {
+ t.Errorf("expected path '%s', got: %s", homeDir, resp.Path)
+ }
+ })
+
+ t.Run("list_nonexistent", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/api/list-directory?path=/nonexistent/path/123456", nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ if resp["error"] == nil {
+ t.Error("expected error field in response")
+ }
+ })
+
+ t.Run("list_file_not_directory", func(t *testing.T) {
+ // Create a temp file
+ f, err := os.CreateTemp("", "test")
+ if err != nil {
+ t.Fatalf("failed to create temp file: %v", err)
+ }
+ defer os.Remove(f.Name())
+ f.Close()
+
+ req := httptest.NewRequest("GET", "/api/list-directory?path="+f.Name(), nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp map[string]interface{}
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ errMsg, ok := resp["error"].(string)
+ if !ok || errMsg != "path is not a directory" {
+ t.Errorf("expected error 'path is not a directory', got: %v", resp["error"])
+ }
+ })
+
+ t.Run("only_directories_returned", func(t *testing.T) {
+ // Create a temp directory with both files and directories
+ tmpDir, err := os.MkdirTemp("", "listdir_test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a subdirectory
+ subDir := tmpDir + "/subdir"
+ if err := os.Mkdir(subDir, 0o755); err != nil {
+ t.Fatalf("failed to create subdir: %v", err)
+ }
+
+ // Create a file
+ file := tmpDir + "/file.txt"
+ if err := os.WriteFile(file, []byte("test"), 0o644); err != nil {
+ t.Fatalf("failed to create file: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ // Should only include the directory, not the file
+ if len(resp.Entries) != 1 {
+ t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
+ }
+
+ if len(resp.Entries) > 0 && resp.Entries[0].Name != "subdir" {
+ t.Errorf("expected entry 'subdir', got: %s", resp.Entries[0].Name)
+ }
+ })
+
+ t.Run("hidden_directories_excluded", func(t *testing.T) {
+ // Create a temp directory with a hidden directory
+ tmpDir, err := os.MkdirTemp("", "listdir_hidden_test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a visible subdirectory
+ visibleDir := tmpDir + "/visible"
+ if err := os.Mkdir(visibleDir, 0o755); err != nil {
+ t.Fatalf("failed to create visible dir: %v", err)
+ }
+
+ // Create a hidden subdirectory
+ hiddenDir := tmpDir + "/.hidden"
+ if err := os.Mkdir(hiddenDir, 0o755); err != nil {
+ t.Fatalf("failed to create hidden dir: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ // Should only include the visible directory, not the hidden one
+ if len(resp.Entries) != 1 {
+ t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
+ }
+
+ if len(resp.Entries) > 0 && resp.Entries[0].Name != "visible" {
+ t.Errorf("expected entry 'visible', got: %s", resp.Entries[0].Name)
+ }
+ })
+}
+
+// TestConversationCwdReturnedInList tests that CWD is returned in the conversations list.
+func TestConversationCwdReturnedInList(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.Close()
+
+ // Create a conversation with a specific CWD
+ h.NewConversation("bash: pwd", "/tmp")
+ h.WaitToolResult() // Wait for the conversation to complete
+
+ // Get the conversations list
+ req := httptest.NewRequest("GET", "/api/conversations", nil)
+ w := httptest.NewRecorder()
+ h.server.handleConversations(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var convs []map[string]interface{}
+ if err := json.Unmarshal(w.Body.Bytes(), &convs); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ if len(convs) == 0 {
+ t.Fatal("expected at least one conversation")
+ }
+
+ // Find our conversation
+ found := false
+ for _, conv := range convs {
+ if conv["conversation_id"] == h.ConversationID() {
+ found = true
+ cwd, ok := conv["cwd"].(string)
+ if !ok {
+ t.Errorf("expected cwd to be a string, got: %T", conv["cwd"])
+ }
+ if cwd != "/tmp" {
+ t.Errorf("expected cwd '/tmp', got: %s", cwd)
+ }
+ break
+ }
+ }
+
+ if !found {
+ t.Error("conversation not found in list")
+ }
+}
@@ -0,0 +1,209 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// TestCancelAfterToolCompletesCreatesDuplicateToolResult reproduces the bug where
+// cancelling a conversation after a tool has already completed creates a duplicate
+// tool_result for the same tool_use_id.
+//
+// The bug is in CancelConversation's search logic: it finds the first tool_use in
+// the last assistant message and immediately breaks without checking if that tool
+// already has a result. This causes it to create a cancelled tool_result even when
+// the tool already completed successfully.
+//
+// This leads to the Anthropic API error:
+// "each tool_use must have a single result. Found multiple `tool_result` blocks with id: ..."
+func TestCancelAfterToolCompletesCreatesDuplicateToolResult(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+ server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Start a conversation with a fast tool call that completes quickly
+ chatReq := ChatRequest{
+ Message: "bash: echo hello",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait for the tool to complete - this is important!
+ // The bash command "echo hello" should complete very quickly
+ deadline := time.Now().Add(5 * time.Second)
+ var toolResultFound bool
+ for time.Now().Before(deadline) {
+ var messages []generated.Message
+ err := database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages: %v", err)
+ }
+
+ // Look for a tool_result message
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult && !content.ToolError {
+ // Found a successful tool result
+ toolResultFound = true
+ break
+ }
+ }
+ if toolResultFound {
+ break
+ }
+ }
+ if toolResultFound {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ if !toolResultFound {
+ t.Fatal("tool result was not found - tool didn't complete")
+ }
+
+ // Give a tiny bit more time for the loop to stabilize
+ time.Sleep(100 * time.Millisecond)
+
+ // Now cancel the conversation AFTER the tool has completed
+ // This should NOT create a new tool_result because the tool already finished
+ cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+ cancelW := httptest.NewRecorder()
+
+ server.handleCancelConversation(cancelW, cancelReq, conversationID)
+ if cancelW.Code != http.StatusOK {
+ t.Fatalf("cancel: expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+ }
+
+ // Wait for cancel to process
+ time.Sleep(200 * time.Millisecond)
+
+ // Check the messages to see if there are duplicate tool_results for the same tool_use_id
+ var messages []generated.Message
+ err = database.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), conversationID)
+ return qerr
+ })
+ if err != nil {
+ t.Fatalf("failed to get messages after cancel: %v", err)
+ }
+
+ // Count tool_results by tool_use_id
+ toolResultsByID := make(map[string]int)
+ for _, msg := range messages {
+ if msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult && content.ToolUseID != "" {
+ toolResultsByID[content.ToolUseID]++
+ }
+ }
+ }
+
+ // Check for duplicates - this is the bug!
+ for toolID, count := range toolResultsByID {
+ if count > 1 {
+ t.Errorf("BUG: found %d tool_results for tool_use_id %s (expected 1)", count, toolID)
+ }
+ }
+
+ // Clear requests to get a clean slate for the next request
+ predictableService.ClearRequests()
+
+ // Now try to continue the conversation - this should trigger the API error
+ // if duplicates exist
+ resumeReq := ChatRequest{
+ Message: "echo: test after cancel",
+ Model: "predictable",
+ }
+ resumeBody, _ := json.Marshal(resumeReq)
+
+ resumeChatReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+ resumeChatReq.Header.Set("Content-Type", "application/json")
+ resumeW := httptest.NewRecorder()
+
+ server.handleChatConversation(resumeW, resumeChatReq, conversationID)
+ if resumeW.Code != http.StatusAccepted {
+ t.Fatalf("resume: expected status 202, got %d: %s", resumeW.Code, resumeW.Body.String())
+ }
+
+ // Wait for the request to be processed
+ time.Sleep(300 * time.Millisecond)
+
+ // Check the last request sent to the LLM for duplicate tool_results
+ lastRequest := predictableService.GetLastRequest()
+ if lastRequest == nil {
+ t.Fatal("no request was sent to the LLM")
+ }
+
+ // Count tool_results in the request by tool_use_id
+ requestToolResultsByID := make(map[string]int)
+ for _, msg := range lastRequest.Messages {
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolResult && content.ToolUseID != "" {
+ requestToolResultsByID[content.ToolUseID]++
+ }
+ }
+ }
+
+ // Check for duplicates in the request - this would cause the Anthropic API error
+ for toolID, count := range requestToolResultsByID {
+ if count > 1 {
+ t.Errorf("BUG: LLM request contains %d tool_results for tool_use_id %s (expected 1). "+
+ "This would cause Anthropic API error: 'each tool_use must have a single result'",
+ count, toolID)
+ }
+ }
+}
@@ -0,0 +1,329 @@
+package server
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GitDiffInfo represents a commit or working changes
+type GitDiffInfo struct {
+ ID string `json:"id"`
+ Message string `json:"message"`
+ Author string `json:"author"`
+ Timestamp time.Time `json:"timestamp"`
+ FilesCount int `json:"filesCount"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+}
+
+// GitFileInfo represents a file in a diff
+type GitFileInfo struct {
+ Path string `json:"path"`
+ Status string `json:"status"` // added, modified, deleted
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+}
+
+// GitFileDiff represents the content of a file diff
+type GitFileDiff struct {
+ Path string `json:"path"`
+ OldContent string `json:"oldContent"`
+ NewContent string `json:"newContent"`
+}
+
+// getGitRoot returns the git repository root for the given directory
+func getGitRoot(dir string) (string, error) {
+ cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(output)), nil
+}
+
+// parseDiffStat parses git diff --numstat output
+func parseDiffStat(output string) (additions, deletions, filesCount int) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ if parts[0] != "-" {
+ add, _ := strconv.Atoi(parts[0])
+ additions += add
+ }
+ if parts[1] != "-" {
+ del, _ := strconv.Atoi(parts[1])
+ deletions += del
+ }
+ filesCount++
+ }
+ }
+ return additions, deletions, filesCount
+}
+
+// handleGitDiffs returns available diffs (working changes + recent commits)
+func (s *Server) handleGitDiffs(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ cwd := r.URL.Query().Get("cwd")
+ if cwd == "" {
+ http.Error(w, "cwd parameter required", http.StatusBadRequest)
+ return
+ }
+
+ // Validate cwd is a directory
+ fi, err := os.Stat(cwd)
+ if err != nil || !fi.IsDir() {
+ http.Error(w, "invalid cwd", http.StatusBadRequest)
+ return
+ }
+
+ gitRoot, err := getGitRoot(cwd)
+ if err != nil {
+ http.Error(w, "not a git repository", http.StatusBadRequest)
+ return
+ }
+
+ var diffs []GitDiffInfo
+
+ // Working changes
+ workingStatCmd := exec.Command("git", "diff", "HEAD", "--numstat")
+ workingStatCmd.Dir = gitRoot
+ workingStatOutput, _ := workingStatCmd.Output()
+ workingAdditions, workingDeletions, workingFilesCount := parseDiffStat(string(workingStatOutput))
+
+ diffs = append(diffs, GitDiffInfo{
+ ID: "working",
+ Message: "Working Changes",
+ Author: "",
+ Timestamp: time.Now(),
+ FilesCount: workingFilesCount,
+ Additions: workingAdditions,
+ Deletions: workingDeletions,
+ })
+
+ // Get commits
+ cmd := exec.Command("git", "log", "--oneline", "-20", "--pretty=format:%H%x00%s%x00%an%x00%at")
+ cmd.Dir = gitRoot
+ output, err := cmd.Output()
+ if err == nil {
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.Split(line, "\x00")
+ if len(parts) < 4 {
+ continue
+ }
+
+ timestamp, _ := strconv.ParseInt(parts[3], 10, 64)
+
+ // Get diffstat
+ statCmd := exec.Command("git", "diff", parts[0]+"^", parts[0], "--numstat")
+ statCmd.Dir = gitRoot
+ statOutput, _ := statCmd.Output()
+ additions, deletions, filesCount := parseDiffStat(string(statOutput))
+
+ diffs = append(diffs, GitDiffInfo{
+ ID: parts[0],
+ Message: parts[1],
+ Author: parts[2],
+ Timestamp: time.Unix(timestamp, 0),
+ FilesCount: filesCount,
+ Additions: additions,
+ Deletions: deletions,
+ })
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "diffs": diffs,
+ "gitRoot": gitRoot,
+ })
+}
+
+// handleGitDiffFiles returns the files changed in a specific diff
+func (s *Server) handleGitDiffFiles(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Extract diff ID from path: /api/git/diffs/{id}/files
+ path := strings.TrimPrefix(r.URL.Path, "/api/git/diffs/")
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) < 2 || parts[1] != "files" {
+ http.Error(w, "invalid path", http.StatusBadRequest)
+ return
+ }
+ diffID := parts[0]
+
+ cwd := r.URL.Query().Get("cwd")
+ if cwd == "" {
+ http.Error(w, "cwd parameter required", http.StatusBadRequest)
+ return
+ }
+
+ gitRoot, err := getGitRoot(cwd)
+ if err != nil {
+ http.Error(w, "not a git repository", http.StatusBadRequest)
+ return
+ }
+
+ var cmd *exec.Cmd
+ var statBaseArg string
+
+ if diffID == "working" {
+ cmd = exec.Command("git", "diff", "--name-status", "HEAD")
+ statBaseArg = "HEAD"
+ } else {
+ cmd = exec.Command("git", "diff", "--name-status", diffID+"^")
+ statBaseArg = diffID + "^"
+ }
+ cmd.Dir = gitRoot
+
+ output, err := cmd.Output()
+ if err != nil {
+ http.Error(w, "failed to get diff files", http.StatusInternalServerError)
+ return
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ var files []GitFileInfo
+
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.Fields(line)
+ if len(parts) < 2 {
+ continue
+ }
+
+ status := "modified"
+ switch parts[0] {
+ case "A":
+ status = "added"
+ case "D":
+ status = "deleted"
+ case "M":
+ status = "modified"
+ }
+
+ // Get additions/deletions for this file
+ statCmd := exec.Command("git", "diff", statBaseArg, "--numstat", "--", parts[1])
+ statCmd.Dir = gitRoot
+ statOutput, _ := statCmd.Output()
+ additions, deletions := 0, 0
+ if statOutput != nil {
+ statParts := strings.Fields(string(statOutput))
+ if len(statParts) >= 2 {
+ additions, _ = strconv.Atoi(statParts[0])
+ deletions, _ = strconv.Atoi(statParts[1])
+ }
+ }
+
+ files = append(files, GitFileInfo{
+ Path: parts[1],
+ Status: status,
+ Additions: additions,
+ Deletions: deletions,
+ })
+ }
+
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].Path < files[j].Path
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(files)
+}
+
+// handleGitFileDiff returns the old and new content for a file
+func (s *Server) handleGitFileDiff(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Extract diff ID and file path from: /api/git/file-diff/{id}/*filepath
+ path := strings.TrimPrefix(r.URL.Path, "/api/git/file-diff/")
+ slashIdx := strings.Index(path, "/")
+ if slashIdx < 0 {
+ http.Error(w, "invalid path", http.StatusBadRequest)
+ return
+ }
+ diffID := path[:slashIdx]
+ filePath := path[slashIdx+1:]
+
+ if diffID == "" || filePath == "" {
+ http.Error(w, "invalid path", http.StatusBadRequest)
+ return
+ }
+
+ cwd := r.URL.Query().Get("cwd")
+ if cwd == "" {
+ http.Error(w, "cwd parameter required", http.StatusBadRequest)
+ return
+ }
+
+ gitRoot, err := getGitRoot(cwd)
+ if err != nil {
+ http.Error(w, "not a git repository", http.StatusBadRequest)
+ return
+ }
+
+ // Prevent path traversal
+ cleanPath := filepath.Clean(filePath)
+ if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
+ http.Error(w, "invalid file path", http.StatusBadRequest)
+ return
+ }
+
+ var oldCmd *exec.Cmd
+ if diffID == "working" {
+ oldCmd = exec.Command("git", "show", "HEAD:"+filePath)
+ } else {
+ oldCmd = exec.Command("git", "show", diffID+"^:"+filePath)
+ }
+ oldCmd.Dir = gitRoot
+
+ oldOutput, _ := oldCmd.Output()
+ oldContent := string(oldOutput)
+
+ // Get new version from working tree
+ newContent := ""
+ fullPath := filepath.Join(gitRoot, cleanPath)
+ if file, err := os.Open(fullPath); err == nil {
+ if fileData, err := io.ReadAll(file); err == nil {
+ newContent = string(fileData)
+ }
+ file.Close()
+ }
+
+ fileDiff := GitFileDiff{
+ Path: filePath,
+ OldContent: oldContent,
+ NewContent: newContent,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(fileDiff)
+}
@@ -0,0 +1,1130 @@
+package server
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/claudetool/browse"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/models"
+ "shelley.exe.dev/slug"
+ "shelley.exe.dev/version"
+)
+
+// handleRead serves files from limited allowed locations via /api/read?path=
+func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ p := r.URL.Query().Get("path")
+ if p == "" {
+ http.Error(w, "path required", http.StatusBadRequest)
+ return
+ }
+ // Clean and enforce prefix restriction
+ clean := p
+ // Do not resolve symlinks here; enforce string prefix restriction only
+ if !(strings.HasPrefix(clean, browse.ScreenshotDir+"/")) {
+ http.Error(w, "path not allowed", http.StatusForbidden)
+ return
+ }
+ f, err := os.Open(clean)
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+ // Determine content type by extension first, then fallback to sniffing
+ ext := strings.ToLower(filepath.Ext(clean))
+ switch ext {
+ case ".png":
+ w.Header().Set("Content-Type", "image/png")
+ case ".jpg", ".jpeg":
+ w.Header().Set("Content-Type", "image/jpeg")
+ case ".gif":
+ w.Header().Set("Content-Type", "image/gif")
+ case ".webp":
+ w.Header().Set("Content-Type", "image/webp")
+ case ".svg":
+ w.Header().Set("Content-Type", "image/svg+xml")
+ default:
+ buf := make([]byte, 512)
+ n, _ := f.Read(buf)
+ contentType := http.DetectContentType(buf[:n])
+ if _, err := f.Seek(0, 0); err != nil {
+ http.Error(w, "seek failed", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", contentType)
+ }
+ // Reasonable short-term caching for assets, allow quick refresh during sessions
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ io.Copy(w, f)
+}
+
+// handleWriteFile writes content to a file (for diff viewer edit mode)
+func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Path string `json:"path"`
+ Content string `json:"content"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Path == "" {
+ http.Error(w, "path required", http.StatusBadRequest)
+ return
+ }
+
+ // Security: only allow writing within certain directories
+ // For now, require the path to be within a git repository
+ clean := filepath.Clean(req.Path)
+ if !filepath.IsAbs(clean) {
+ http.Error(w, "absolute path required", http.StatusBadRequest)
+ return
+ }
+
+ // Write the file
+ if err := os.WriteFile(clean, []byte(req.Content), 0o644); err != nil {
+ http.Error(w, fmt.Sprintf("failed to write file: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+// handleUpload handles file uploads via POST /api/upload
+// Files are saved to the ScreenshotDir with a random filename
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Limit to 10MB file size
+ r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
+
+ // Parse the multipart form
+ if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
+ http.Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Get the file from the multipart form
+ file, handler, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ // Generate a unique ID (8 random bytes converted to 16 hex chars)
+ randBytes := make([]byte, 8)
+ if _, err := rand.Read(randBytes); err != nil {
+ http.Error(w, "failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Get file extension from the original filename
+ ext := filepath.Ext(handler.Filename)
+
+ // Create a unique filename in the ScreenshotDir
+ filename := filepath.Join(browse.ScreenshotDir, fmt.Sprintf("upload_%s%s", hex.EncodeToString(randBytes), ext))
+
+ // Ensure the directory exists
+ if err := os.MkdirAll(browse.ScreenshotDir, 0o755); err != nil {
+ http.Error(w, "failed to create directory: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Create the destination file
+ destFile, err := os.Create(filename)
+ if err != nil {
+ http.Error(w, "failed to create destination file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer destFile.Close()
+
+ // Copy the file contents to the destination file
+ if _, err := io.Copy(destFile, file); err != nil {
+ http.Error(w, "failed to save file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Return the path to the saved file
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"path": filename})
+}
+
+// staticHandler serves files from the provided filesystem and disables caching for HTML/CSS/JS to avoid stale bundles
+// isConversationSlugPath returns true if the path looks like a conversation slug route
+// (e.g., /c/my-conversation-slug)
+func isConversationSlugPath(path string) bool {
+ return strings.HasPrefix(path, "/c/")
+}
+
+func (s *Server) staticHandler(fs http.FileSystem) http.Handler {
+ fileServer := http.FileServer(fs)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Inject initialization data into index.html
+ if r.URL.Path == "/" || r.URL.Path == "/index.html" || isConversationSlugPath(r.URL.Path) {
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+ w.Header().Set("Content-Type", "text/html")
+ s.serveIndexWithInit(w, r, fs)
+ return
+ }
+
+ if strings.HasSuffix(r.URL.Path, ".html") || strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".css") {
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+}
+
+// hashString computes a simple hash of a string
+func hashString(s string) uint32 {
+ var hash uint32
+ for _, c := range s {
+ hash = ((hash << 5) - hash) + uint32(c)
+ }
+ return hash
+}
+
+// generateFaviconSVG creates a seashell favicon with color based on hostname hash
+func generateFaviconSVG(hostname string) string {
+ hash := hashString(hostname)
+ h := hash % 360
+ s := 55
+ l := 65
+ lightL := l + 15
+ if lightL > 90 {
+ lightL = 90
+ }
+ darkL := l - 15
+ if darkL < 40 {
+ darkL = 40
+ }
+ strokeL := darkL - 15
+ if strokeL < 25 {
+ strokeL = 25
+ }
+
+ return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <defs>
+ <linearGradient id="shellGrad" x1="0%%" y1="0%%" x2="100%%" y2="100%%">
+ <stop offset="0%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+ <stop offset="50%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+ <stop offset="100%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+ </linearGradient>
+ </defs>
+ <path d="M16 4 C8 4 3 12 3 20 C3 24 6 28 16 28 C26 28 29 24 29 20 C29 12 24 4 16 4"
+ fill="url(#shellGrad)" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1"/>
+ <path d="M16 6 L16 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+ <path d="M16 6 L8 25" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+ <path d="M16 6 L24 25" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+ <path d="M16 6 L5 22" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+ <path d="M16 6 L27 22" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+ <path d="M16 6 L11 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="0.8" fill="none"/>
+ <path d="M16 6 L21 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="0.8" fill="none"/>
+</svg>`,
+ h, s, lightL,
+ h, s, l,
+ h, s, darkL,
+ h, s-10, strokeL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ h, s-20, darkL,
+ )
+}
+
+// serveIndexWithInit serves index.html with injected initialization data
+func (s *Server) serveIndexWithInit(w http.ResponseWriter, r *http.Request, fs http.FileSystem) {
+ // Read index.html from the filesystem
+ file, err := fs.Open("/index.html")
+ if err != nil {
+ http.Error(w, "index.html not found", http.StatusNotFound)
+ return
+ }
+ defer file.Close()
+
+ indexHTML, err := io.ReadAll(file)
+ if err != nil {
+ http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
+ return
+ }
+
+ // Build initialization data
+ type ModelInfo struct {
+ ID string `json:"id"`
+ Ready bool `json:"ready"`
+ MaxContextTokens int `json:"max_context_tokens,omitempty"`
+ }
+
+ var modelList []ModelInfo
+ if s.predictableOnly {
+ modelList = append(modelList, ModelInfo{ID: "predictable", Ready: true, MaxContextTokens: 200000})
+ } else {
+ modelIDs := s.llmManager.GetAvailableModels()
+ for _, id := range modelIDs {
+ // Skip predictable model unless predictable-only flag is set
+ if id == "predictable" {
+ continue
+ }
+ svc, err := s.llmManager.GetService(id)
+ maxCtx := 0
+ if err == nil && svc != nil {
+ maxCtx = svc.TokenContextWindow()
+ }
+ modelList = append(modelList, ModelInfo{ID: id, Ready: err == nil, MaxContextTokens: maxCtx})
+ }
+ }
+
+ // Select default model - use configured default if available, otherwise first ready model
+ defaultModel := s.defaultModel
+ if defaultModel == "" {
+ defaultModel = models.Default().ID
+ }
+ defaultModelAvailable := false
+ for _, m := range modelList {
+ if m.ID == defaultModel && m.Ready {
+ defaultModelAvailable = true
+ break
+ }
+ }
+ if !defaultModelAvailable {
+ // Fall back to first ready model
+ for _, m := range modelList {
+ if m.Ready {
+ defaultModel = m.ID
+ break
+ }
+ }
+ }
+
+ // Get hostname
+ hostname := "localhost"
+ if h, err := os.Hostname(); err == nil {
+ hostname = h
+ }
+
+ // Get default working directory
+ defaultCwd, err := os.Getwd()
+ if err != nil {
+ defaultCwd = "/"
+ }
+
+ // Get home directory for tilde display
+ homeDir, _ := os.UserHomeDir()
+
+ initData := map[string]interface{}{
+ "models": modelList,
+ "default_model": defaultModel,
+ "hostname": hostname,
+ "default_cwd": defaultCwd,
+ "home_dir": homeDir,
+ }
+ if s.terminalURL != "" {
+ initData["terminal_url"] = s.terminalURL
+ }
+ if len(s.links) > 0 {
+ initData["links"] = s.links
+ }
+
+ initJSON, err := json.Marshal(initData)
+ if err != nil {
+ http.Error(w, "Failed to marshal init data", http.StatusInternalServerError)
+ return
+ }
+
+ // Generate favicon as data URI
+ faviconSVG := generateFaviconSVG(hostname)
+ faviconDataURI := "data:image/svg+xml," + url.PathEscape(faviconSVG)
+ faviconLink := fmt.Sprintf(`<link rel="icon" type="image/svg+xml" href="%s"/>`, faviconDataURI)
+
+ // Inject the script tag and favicon before </head>
+ initScript := fmt.Sprintf(`<script>window.__SHELLEY_INIT__=%s;</script>`, initJSON)
+ injection := faviconLink + initScript
+ modifiedHTML := strings.Replace(string(indexHTML), "</head>", injection+"</head>", 1)
+
+ w.Write([]byte(modifiedHTML))
+}
+
+// handleConfig returns server configuration
+// handleConversations handles GET /conversations
+func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ ctx := r.Context()
+ limit := 5000
+ offset := 0
+ var query string
+
+ // Parse query parameters
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+ limit = l
+ }
+ }
+ if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+ if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+ offset = o
+ }
+ }
+ query = r.URL.Query().Get("q")
+
+ // Get conversations from database
+ var conversations []generated.Conversation
+ var err error
+
+ if query != "" {
+ conversations, err = s.db.SearchConversations(ctx, query, int64(limit), int64(offset))
+ } else {
+ conversations, err = s.db.ListConversations(ctx, int64(limit), int64(offset))
+ }
+
+ if err != nil {
+ s.logger.Error("Failed to get conversations", "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversations)
+}
+
+// handleConversation handles conversation-specific routes
+func (s *Server) handleConversation(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/api/conversation/")
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) == 0 || parts[0] == "" {
+ http.Error(w, "Conversation ID required", http.StatusBadRequest)
+ return
+ }
+
+ conversationID := parts[0]
+
+ // Handle different endpoints
+ if len(parts) == 1 {
+ // /conversation/<id>
+ s.handleGetConversation(w, r, conversationID)
+ } else {
+ switch parts[1] {
+ case "stream":
+ // /conversation/<id>/stream
+ s.handleStreamConversation(w, r, conversationID)
+ case "chat":
+ // /conversation/<id>/chat
+ s.handleChatConversation(w, r, conversationID)
+ case "cancel":
+ // /conversation/<id>/cancel
+ s.handleCancelConversation(w, r, conversationID)
+ case "archive":
+ // /conversation/<id>/archive
+ s.handleArchiveConversation(w, r, conversationID)
+ case "unarchive":
+ // /conversation/<id>/unarchive
+ s.handleUnarchiveConversation(w, r, conversationID)
+ case "delete":
+ // /conversation/<id>/delete
+ s.handleDeleteConversation(w, r, conversationID)
+ case "rename":
+ // /conversation/<id>/rename
+ s.handleRenameConversation(w, r, conversationID)
+ default:
+ http.Error(w, "Not found", http.StatusNotFound)
+ }
+ }
+}
+
+// handleGetConversation handles GET /conversation/<id>
+func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+ var (
+ messages []generated.Message
+ conversation generated.Conversation
+ )
+ err := s.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ messages, err = q.ListMessages(ctx, conversationID)
+ if err != nil {
+ return err
+ }
+ conversation, err = q.GetConversation(ctx, conversationID)
+ return err
+ })
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ http.Error(w, "Conversation not found", http.StatusNotFound)
+ return
+ }
+ s.logger.Error("Failed to get conversation messages", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ apiMessages := toAPIMessages(messages)
+ json.NewEncoder(w).Encode(StreamResponse{
+ Messages: apiMessages,
+ Conversation: conversation,
+ AgentWorking: agentWorking(apiMessages),
+ ContextWindowSize: calculateContextWindowSize(apiMessages),
+ })
+}
+
+// ChatRequest represents a chat message from the user
+type ChatRequest struct {
+ Message string `json:"message"`
+ Model string `json:"model,omitempty"`
+ Cwd string `json:"cwd,omitempty"`
+}
+
+// handleChatConversation handles POST /conversation/<id>/chat
+func (s *Server) handleChatConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+
+ // Parse request
+ var req ChatRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if req.Message == "" {
+ http.Error(w, "Message is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get LLM service for the requested model
+ modelID := req.Model
+ if modelID == "" {
+ modelID = s.defaultModel
+ }
+
+ llmService, err := s.llmManager.GetService(modelID)
+ if err != nil {
+ s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
+ http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
+ return
+ }
+
+ // Get or create conversation manager
+ manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+ if err != nil {
+ if errors.Is(err, errConversationModelMismatch) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Create user message
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: req.Message},
+ },
+ }
+
+ firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+ if err != nil {
+ if errors.Is(err, errConversationModelMismatch) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ if firstMessage {
+ ctxNoCancel := context.WithoutCancel(ctx)
+ go func() {
+ slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
+ defer cancel()
+ _, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
+ if err != nil {
+ s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
+ } else {
+ go s.notifySubscribers(ctxNoCancel, conversationID)
+ }
+ }()
+ }
+
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
+}
+
+// handleNewConversation handles POST /api/conversations/new - creates conversation implicitly on first message
+func (s *Server) handleNewConversation(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+
+ // Parse request
+ var req ChatRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if req.Message == "" {
+ http.Error(w, "Message is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get LLM service for the requested model
+ modelID := req.Model
+ if modelID == "" {
+ // Default to Qwen3 Coder on Fireworks
+ modelID = "qwen3-coder-fireworks"
+ }
+
+ llmService, err := s.llmManager.GetService(modelID)
+ if err != nil {
+ s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
+ http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
+ return
+ }
+
+ // Create new conversation with optional cwd
+ var cwdPtr *string
+ if req.Cwd != "" {
+ cwdPtr = &req.Cwd
+ }
+ conversation, err := s.db.CreateConversation(ctx, nil, true, cwdPtr)
+ if err != nil {
+ s.logger.Error("Failed to create conversation", "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ conversationID := conversation.ConversationID
+
+ // Get or create conversation manager
+ manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+ if err != nil {
+ if errors.Is(err, errConversationModelMismatch) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Create user message
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: req.Message},
+ },
+ }
+
+ firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+ if err != nil {
+ if errors.Is(err, errConversationModelMismatch) {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ if firstMessage {
+ ctxNoCancel := context.WithoutCancel(ctx)
+ go func() {
+ slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
+ defer cancel()
+ _, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
+ if err != nil {
+ s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
+ } else {
+ go s.notifySubscribers(ctxNoCancel, conversationID)
+ }
+ }()
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "accepted",
+ "conversation_id": conversationID,
+ })
+}
+
+// handleCancelConversation handles POST /conversation/<id>/cancel
+func (s *Server) handleCancelConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+
+ // Get the conversation manager if it exists
+ s.mu.Lock()
+ manager, exists := s.activeConversations[conversationID]
+ s.mu.Unlock()
+
+ if !exists {
+ // No active conversation to cancel
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"status": "no_active_conversation"})
+ return
+ }
+
+ // Cancel the conversation
+ if err := manager.CancelConversation(ctx); err != nil {
+ s.logger.Error("Failed to cancel conversation", "conversationID", conversationID, "error", err)
+ http.Error(w, "Failed to cancel conversation", http.StatusInternalServerError)
+ return
+ }
+
+ s.logger.Info("Conversation cancelled", "conversationID", conversationID)
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"status": "cancelled"})
+}
+
+// handleStreamConversation handles GET /conversation/<id>/stream
+func (s *Server) handleStreamConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+
+ // Set up SSE headers
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ // Get current messages and conversation data
+ var messages []generated.Message
+ var conversation generated.Conversation
+ err := s.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ messages, err = q.ListMessages(ctx, conversationID)
+ if err != nil {
+ return err
+ }
+ conversation, err = q.GetConversation(ctx, conversationID)
+ return err
+ })
+ if err != nil {
+ s.logger.Error("Failed to get conversation data", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Send current messages and conversation data
+ apiMessages := toAPIMessages(messages)
+ streamData := StreamResponse{
+ Messages: apiMessages,
+ Conversation: conversation,
+ AgentWorking: agentWorking(apiMessages),
+ ContextWindowSize: calculateContextWindowSize(apiMessages),
+ }
+ data, _ := json.Marshal(streamData)
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ w.(http.Flusher).Flush()
+
+ // Get or create conversation manager
+ manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+ if err != nil {
+ s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+ return
+ }
+
+ // Subscribe to new messages after the last one we sent
+ last := int64(-1)
+ if len(messages) > 0 {
+ last = messages[len(messages)-1].SequenceID
+ }
+ next := manager.subpub.Subscribe(ctx, last)
+ for {
+ streamData, cont := next()
+ if !cont {
+ break
+ }
+ // Always forward updates, even if only the conversation changed (e.g., slug added)
+ data, _ := json.Marshal(streamData)
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ w.(http.Flusher).Flush()
+ }
+}
+
+// handleDebugLLM serves recent LLM requests and responses for debugging
+func (s *Server) handleDebugLLM(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Check if requesting a specific record JSON
+ if idx := r.URL.Query().Get("index"); idx != "" {
+ var i int
+ if _, err := fmt.Sscanf(idx, "%d", &i); err != nil {
+ http.Error(w, "Invalid index", http.StatusBadRequest)
+ return
+ }
+
+ type historyProvider interface {
+ GetHistory() *models.LLMRequestHistory
+ }
+
+ var records []models.LLMRequestRecord
+ if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
+ records = hp.GetHistory().GetRecords()
+ }
+
+ if i < 0 || i >= len(records) {
+ http.Error(w, "Index out of range", http.StatusNotFound)
+ return
+ }
+
+ record := records[i]
+ recordType := r.URL.Query().Get("type")
+
+ switch recordType {
+ case "request":
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(record.HTTPRequest)
+ case "response":
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(record.HTTPResponse)
+ default:
+ // Return the full record
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(record)
+ }
+ return
+ }
+
+ // Get history from the LLM manager if it's a models.Manager
+ type historyProvider interface {
+ GetHistory() *models.LLMRequestHistory
+ }
+
+ var records []models.LLMRequestRecord
+ if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
+ records = hp.GetHistory().GetRecords()
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+
+ // Write simple HTML with links to JSON
+ fmt.Fprint(w, `<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>LLM Debug - Recent Requests</title>
+<style>
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ margin: 20px;
+ background: #ffffff;
+ color: #000000;
+}
+h1 {
+ margin-bottom: 20px;
+}
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+th, td {
+ padding: 8px 12px;
+ text-align: left;
+ border-bottom: 1px solid #ddd;
+}
+th {
+ background: #f5f5f5;
+ font-weight: 600;
+}
+tr:hover {
+ background: #f9f9f9;
+}
+.error {
+ color: #d32f2f;
+}
+.success {
+ color: #388e3c;
+}
+a {
+ color: #1976d2;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+</style>
+</head>
+<body>
+<h1>LLM Debug - Recent Requests</h1>
+`)
+
+ if len(records) == 0 {
+ fmt.Fprint(w, "<p>No requests recorded yet.</p>")
+ } else {
+ fmt.Fprint(w, "<table>")
+ fmt.Fprint(w, "<tr><th>#</th><th>Time</th><th>Model</th><th>URL</th><th>Status</th><th>Duration</th><th>Request</th><th>Response</th></tr>")
+ for i := len(records) - 1; i >= 0; i-- {
+ record := records[i]
+ num := len(records) - i
+ statusClass := "success"
+ statusText := fmt.Sprintf("%d", record.HTTPStatusCode)
+ if record.Error != "" {
+ statusClass = "error"
+ statusText = record.Error
+ } else if record.HTTPStatusCode >= 400 {
+ statusClass = "error"
+ }
+ fmt.Fprintf(w, "<tr>")
+ fmt.Fprintf(w, "<td>%d</td>", num)
+ fmt.Fprintf(w, "<td>%s</td>", record.Timestamp.Format("15:04:05"))
+ fmt.Fprintf(w, "<td>%s</td>", record.ModelID)
+ fmt.Fprintf(w, "<td>%s</td>", record.URL)
+ fmt.Fprintf(w, "<td class=\"%s\">%s</td>", statusClass, statusText)
+ fmt.Fprintf(w, "<td>%.2fs</td>", record.Duration)
+ fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=request\" target=\"_blank\">json</a></td>", i)
+ fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=response\" target=\"_blank\">json</a></td>", i)
+ fmt.Fprintf(w, "</tr>")
+ }
+ fmt.Fprint(w, "</table>")
+ }
+
+ fmt.Fprint(w, `
+</body>
+</html>
+`)
+}
+
+// handleVersion returns version information as JSON
+func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(version.GetInfo())
+}
+
+// handleArchivedConversations handles GET /api/conversations/archived
+func (s *Server) handleArchivedConversations(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ ctx := r.Context()
+ limit := 5000
+ offset := 0
+ var query string
+
+ // Parse query parameters
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+ limit = l
+ }
+ }
+ if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+ if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+ offset = o
+ }
+ }
+ query = r.URL.Query().Get("q")
+
+ // Get archived conversations from database
+ var conversations []generated.Conversation
+ var err error
+
+ if query != "" {
+ conversations, err = s.db.SearchArchivedConversations(ctx, query, int64(limit), int64(offset))
+ } else {
+ conversations, err = s.db.ListArchivedConversations(ctx, int64(limit), int64(offset))
+ }
+
+ if err != nil {
+ s.logger.Error("Failed to get archived conversations", "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversations)
+}
+
+// handleArchiveConversation handles POST /conversation/<id>/archive
+func (s *Server) handleArchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+ conversation, err := s.db.ArchiveConversation(ctx, conversationID)
+ if err != nil {
+ s.logger.Error("Failed to archive conversation", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversation)
+}
+
+// handleUnarchiveConversation handles POST /conversation/<id>/unarchive
+func (s *Server) handleUnarchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+ conversation, err := s.db.UnarchiveConversation(ctx, conversationID)
+ if err != nil {
+ s.logger.Error("Failed to unarchive conversation", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversation)
+}
+
+// handleDeleteConversation handles POST /conversation/<id>/delete
+func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+ if err := s.db.DeleteConversation(ctx, conversationID); err != nil {
+ s.logger.Error("Failed to delete conversation", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
+}
+
+// handleConversationBySlug handles GET /api/conversation-by-slug/<slug>
+func (s *Server) handleConversationBySlug(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ slug := strings.TrimPrefix(r.URL.Path, "/api/conversation-by-slug/")
+ if slug == "" {
+ http.Error(w, "Slug required", http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+ conversation, err := s.db.GetConversationBySlug(ctx, slug)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ http.Error(w, "Conversation not found", http.StatusNotFound)
+ return
+ }
+ s.logger.Error("Failed to get conversation by slug", "slug", slug, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversation)
+}
+
+// RenameRequest represents a request to rename a conversation
+type RenameRequest struct {
+ Slug string `json:"slug"`
+}
+
+// handleRenameConversation handles POST /conversation/<id>/rename
+func (s *Server) handleRenameConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := r.Context()
+
+ var req RenameRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ // Sanitize the slug using the same rules as auto-generated slugs
+ sanitized := slug.Sanitize(req.Slug)
+ if sanitized == "" {
+ http.Error(w, "Slug is required (must contain alphanumeric characters)", http.StatusBadRequest)
+ return
+ }
+
+ conversation, err := s.db.UpdateConversationSlug(ctx, conversationID, sanitized)
+ if err != nil {
+ s.logger.Error("Failed to rename conversation", "conversationID", conversationID, "error", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(conversation)
+}
@@ -0,0 +1,33 @@
+package server
+
+import "log/slog"
+
+// Link represents a custom link to be displayed in the UI
+type Link struct {
+ Title string `json:"title"`
+ IconSVG string `json:"icon_svg,omitempty"` // SVG path data for the icon
+ URL string `json:"url"`
+}
+
+// LLMConfig holds all configuration for LLM services
+type LLMConfig struct {
+ // API keys for each provider
+ AnthropicAPIKey string
+ OpenAIAPIKey string
+ GeminiAPIKey string
+ FireworksAPIKey string
+
+ // Gateway is the base URL of the LLM gateway (optional)
+ Gateway string
+
+ // TerminalURL is the URL to the terminal interface (optional)
+ TerminalURL string
+
+ // DefaultModel is the default model to use (optional, defaults to models.Default())
+ DefaultModel string
+
+ // Links are custom links to be displayed in the UI (optional)
+ Links []Link
+
+ Logger *slog.Logger
+}
@@ -0,0 +1,292 @@
+package server
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/loop"
+)
+
+// TestMessageSentOnlyOnce verifies that each message is sent to SSE subscribers
+// only once, not with every update.
+func TestMessageSentOnlyOnce(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Set up real HTTP server
+ mux := http.NewServeMux()
+ server.RegisterRoutes(mux)
+ httpServer := httptest.NewServer(mux)
+ defer httpServer.Close()
+
+ // Connect to SSE stream
+ sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+ if err != nil {
+ t.Fatalf("failed to connect to SSE stream: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ // Start reading SSE events in background
+ type sseEvent struct {
+ data StreamResponse
+ msgCount int
+ totalSize int
+ }
+ sseEvents := make(chan sseEvent, 100)
+
+ go func() {
+ scanner := bufio.NewScanner(sseResp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ jsonStr := strings.TrimPrefix(line, "data: ")
+ var streamResp StreamResponse
+ if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+ continue
+ }
+ sseEvents <- sseEvent{
+ data: streamResp,
+ msgCount: len(streamResp.Messages),
+ totalSize: len(jsonStr),
+ }
+ }
+ }()
+
+ // Wait for initial SSE event (empty)
+ select {
+ case ev := <-sseEvents:
+ t.Logf("Initial SSE event: %d messages, %d bytes", ev.msgCount, ev.totalSize)
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for initial SSE event")
+ }
+
+ // Send first user message
+ chatReq := ChatRequest{
+ Message: "hello",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+ "application/json",
+ strings.NewReader(string(chatBody)),
+ )
+ if err != nil {
+ t.Fatalf("failed to send chat message: %v", err)
+ }
+ resp.Body.Close()
+
+ // Collect SSE events for a short time to see the message progression
+ var receivedEvents []sseEvent
+ deadline := time.Now().Add(3 * time.Second)
+
+ for time.Now().Before(deadline) {
+ select {
+ case ev := <-sseEvents:
+ receivedEvents = append(receivedEvents, ev)
+ t.Logf("SSE event %d: %d messages, %d bytes", len(receivedEvents), ev.msgCount, ev.totalSize)
+
+ // Check if we have end_of_turn
+ if len(ev.data.Messages) > 0 {
+ lastMsg := ev.data.Messages[len(ev.data.Messages)-1]
+ if lastMsg.EndOfTurn != nil && *lastMsg.EndOfTurn {
+ t.Log("Got end_of_turn, stopping collection")
+ goto done
+ }
+ }
+ case <-time.After(100 * time.Millisecond):
+ // Keep waiting
+ }
+ }
+
+done:
+ if len(receivedEvents) == 0 {
+ t.Fatal("received no SSE events after sending message")
+ }
+
+ // Analyze: count how many times each message was sent
+ messagesSent := make(map[int64]int) // sequence_id -> count
+ totalBytes := 0
+
+ for _, ev := range receivedEvents {
+ totalBytes += ev.totalSize
+ for _, msg := range ev.data.Messages {
+ messagesSent[msg.SequenceID]++
+ }
+ }
+
+ t.Logf("Total bytes sent across all SSE events: %d", totalBytes)
+ t.Logf("Message send counts:")
+ for seqID, count := range messagesSent {
+ t.Logf(" Sequence %d: sent %d times", seqID, count)
+ if count > 1 {
+ t.Errorf("BUG: Message with sequence_id=%d was sent %d times (expected 1)", seqID, count)
+ }
+ }
+}
+
+// TestContextWindowSizeInSSE verifies that context_window_size is correctly
+// included only when agent messages with usage data are sent.
+func TestContextWindowSizeInSSE(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Set up real HTTP server
+ mux := http.NewServeMux()
+ server.RegisterRoutes(mux)
+ httpServer := httptest.NewServer(mux)
+ defer httpServer.Close()
+
+ // Connect to SSE stream
+ sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+ if err != nil {
+ t.Fatalf("failed to connect to SSE stream: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ // Start reading SSE events in background
+ type sseEvent struct {
+ data StreamResponse
+ contextWindowSize uint64
+ hasContextWindow bool
+ }
+ sseEvents := make(chan sseEvent, 100)
+
+ go func() {
+ scanner := bufio.NewScanner(sseResp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ jsonStr := strings.TrimPrefix(line, "data: ")
+ var streamResp StreamResponse
+ if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+ continue
+ }
+ // Check if context_window_size was present in the JSON
+ var raw map[string]interface{}
+ json.Unmarshal([]byte(jsonStr), &raw)
+ _, hasCtx := raw["context_window_size"]
+
+ sseEvents <- sseEvent{
+ data: streamResp,
+ contextWindowSize: streamResp.ContextWindowSize,
+ hasContextWindow: hasCtx,
+ }
+ }
+ }()
+
+ // Wait for initial SSE event (empty)
+ select {
+ case ev := <-sseEvents:
+ t.Logf("Initial: context_window_size present=%v value=%d", ev.hasContextWindow, ev.contextWindowSize)
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for initial SSE event")
+ }
+
+ // Send user message
+ chatReq := ChatRequest{
+ Message: "hello",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+ "application/json",
+ strings.NewReader(string(chatBody)),
+ )
+ if err != nil {
+ t.Fatalf("failed to send chat message: %v", err)
+ }
+ resp.Body.Close()
+
+ // Collect SSE events
+ var receivedEvents []sseEvent
+ deadline := time.Now().Add(3 * time.Second)
+
+ for time.Now().Before(deadline) {
+ select {
+ case ev := <-sseEvents:
+ receivedEvents = append(receivedEvents, ev)
+ msgType := "unknown"
+ if len(ev.data.Messages) > 0 {
+ msgType = ev.data.Messages[0].Type
+ }
+ t.Logf("Event %d: type=%s context_window_size present=%v value=%d",
+ len(receivedEvents), msgType, ev.hasContextWindow, ev.contextWindowSize)
+
+ // Check if we have end_of_turn
+ if len(ev.data.Messages) > 0 {
+ lastMsg := ev.data.Messages[len(ev.data.Messages)-1]
+ if lastMsg.EndOfTurn != nil && *lastMsg.EndOfTurn {
+ goto done
+ }
+ }
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+
+done:
+ // Verify: user messages should NOT have context_window_size (omitted via omitempty)
+ // Agent messages with usage data SHOULD have context_window_size
+ for i, ev := range receivedEvents {
+ if len(ev.data.Messages) == 0 {
+ continue
+ }
+ msg := ev.data.Messages[0]
+ if msg.Type == "user" {
+ // User messages have no usage data, context_window_size should be omitted (0)
+ if ev.hasContextWindow && ev.contextWindowSize != 0 {
+ t.Errorf("Event %d: user message should not have context_window_size, got %d", i+1, ev.contextWindowSize)
+ }
+ } else if msg.Type == "agent" && msg.UsageData != nil {
+ // Agent messages with usage data should have context_window_size
+ if !ev.hasContextWindow {
+ t.Errorf("Event %d: agent message with usage data should have context_window_size", i+1)
+ }
+ if ev.contextWindowSize == 0 {
+ t.Errorf("Event %d: agent message context_window_size should not be 0", i+1)
+ }
+ }
+ }
+}
@@ -0,0 +1,57 @@
+package server
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+
+ sloghttp "github.com/samber/slog-http"
+)
+
+// LoggerMiddleware adds request logging using slog-http
+func LoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
+ config := sloghttp.Config{
+ DefaultLevel: slog.LevelInfo,
+ ClientErrorLevel: slog.LevelInfo,
+ ServerErrorLevel: slog.LevelInfo,
+ WithRequestID: false,
+ }
+ return sloghttp.NewWithConfig(logger, config)
+}
+
+// CSRFMiddleware protects against CSRF attacks by requiring the X-Shelley-Request header
+// on state-changing requests (POST, PUT, DELETE). This works because browsers will not
+// add custom headers to simple cross-origin requests, and CORS preflight will block
+// complex requests from other origins that don't have explicit permission.
+func CSRFMiddleware() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Only check state-changing methods
+ if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
+ // Require X-Shelley-Request header (value doesn't matter, just presence)
+ if r.Header.Get("X-Shelley-Request") == "" {
+ http.Error(w, "CSRF protection: X-Shelley-Request header required", http.StatusForbidden)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequireHeaderMiddleware requires a specific header to be present on all API requests.
+// This is used to ensure requests come through an authenticated proxy.
+func RequireHeaderMiddleware(headerName string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Only check API routes
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ if r.Header.Get(headerName) == "" {
+ http.Error(w, "missing required header: "+headerName, http.StatusForbidden)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
@@ -0,0 +1,144 @@
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCSRFMiddleware_BlocksPostWithoutHeader(t *testing.T) {
+ handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("POST", "/api/test", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected status 403 for POST without X-Shelley-Request, got %d", w.Code)
+ }
+}
+
+func TestCSRFMiddleware_AllowsPostWithHeader(t *testing.T) {
+ handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("POST", "/api/test", nil)
+ req.Header.Set("X-Shelley-Request", "1")
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200 for POST with X-Shelley-Request, got %d", w.Code)
+ }
+}
+
+func TestCSRFMiddleware_AllowsGetWithoutHeader(t *testing.T) {
+ handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/test", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200 for GET without X-Shelley-Request, got %d", w.Code)
+ }
+}
+
+func TestCSRFMiddleware_BlocksPutWithoutHeader(t *testing.T) {
+ handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("PUT", "/api/test", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected status 403 for PUT without X-Shelley-Request, got %d", w.Code)
+ }
+}
+
+func TestCSRFMiddleware_BlocksDeleteWithoutHeader(t *testing.T) {
+ handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("DELETE", "/api/test", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected status 403 for DELETE without X-Shelley-Request, got %d", w.Code)
+ }
+}
+
+func TestRequireHeaderMiddleware_BlocksAPIWithoutHeader(t *testing.T) {
+ handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/conversations", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected status 403 for API request without required header, got %d", w.Code)
+ }
+}
+
+func TestRequireHeaderMiddleware_AllowsAPIWithHeader(t *testing.T) {
+ handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/conversations", nil)
+ req.Header.Set("X-Exedev-Userid", "user123")
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200 for API request with required header, got %d", w.Code)
+ }
+}
+
+func TestRequireHeaderMiddleware_AllowsNonAPIWithoutHeader(t *testing.T) {
+ handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200 for non-API request without required header, got %d", w.Code)
+ }
+}
+
+func TestRequireHeaderMiddleware_AllowsVersionEndpointWithoutHeader(t *testing.T) {
+ handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/version", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200 for /version without required header, got %d", w.Code)
+ }
+}
@@ -0,0 +1,339 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// TestOrphanToolResultAfterCancellation reproduces the bug where a tool_result
+// is written after CancelConversation has already written an end-turn message.
+//
+// This leads to the Anthropic API error:
+// "unexpected `tool_use_id` found in `tool_result` blocks: <id>.
+// Each `tool_result` block must have a corresponding `tool_use` block in the previous message."
+//
+// The sequence is:
+// 1. LLM returns assistant message with tool_use X
+// 2. Tool X starts executing
+// 3. User cancels
+// 4. CancelConversation writes:
+// - user message with cancelled tool_result X
+// - assistant message with end-turn "[Operation cancelled]"
+//
+// 5. Tool X completes and writes its result AFTER the cancel messages
+// 6. DB now has:
+// - assistant with tool_use X
+// - user with tool_result X (cancelled)
+// - assistant end-turn
+// - user with tool_result X (actual) <- ORPHAN - references X but previous msg has no tool_use!
+func TestOrphanToolResultAfterCancellation(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+ server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Manually create the problematic message sequence in the database
+ // This simulates the race condition where a tool result is written after cancellation
+
+ toolUseID := "toolu_test_orphan_12345"
+
+ // Message 1: User message "run something"
+ userMsg1 := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "bash: echo hello"},
+ },
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeUser,
+ LLMData: userMsg1,
+ UsageData: llm.Usage{},
+ }); err != nil {
+ t.Fatalf("failed to create user message: %v", err)
+ }
+
+ // Message 2: Assistant message with tool_use
+ assistantMsg1 := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "I'll run the command"},
+ {
+ ID: toolUseID,
+ Type: llm.ContentTypeToolUse,
+ ToolName: "bash",
+ ToolInput: json.RawMessage(`{"command": "echo hello"}`),
+ },
+ },
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeAgent,
+ LLMData: assistantMsg1,
+ UsageData: llm.Usage{},
+ }); err != nil {
+ t.Fatalf("failed to create assistant message: %v", err)
+ }
+
+ // Message 3: User message with cancelled tool_result (from CancelConversation)
+ now := time.Now()
+ cancelledToolResult := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: toolUseID,
+ ToolError: true,
+ ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "Tool execution cancelled by user"}},
+ ToolUseStartTime: &now,
+ ToolUseEndTime: &now,
+ },
+ },
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeUser,
+ LLMData: cancelledToolResult,
+ UsageData: llm.Usage{},
+ }); err != nil {
+ t.Fatalf("failed to create cancelled tool_result message: %v", err)
+ }
+
+ // Message 4: Assistant end-turn message (from CancelConversation)
+ endTurnMsg := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "[Operation cancelled]"}},
+ EndOfTurn: true,
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeAgent,
+ LLMData: endTurnMsg,
+ UsageData: llm.Usage{},
+ }); err != nil {
+ t.Fatalf("failed to create end-turn message: %v", err)
+ }
+
+ // Message 5: ORPHAN - User message with actual tool_result (written after cancel due to race)
+ // This references the tool_use from message 2, but the previous message (4) has no tool_use!
+ actualToolResult := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: toolUseID,
+ ToolError: false,
+ ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "hello\n"}},
+ ToolUseStartTime: &now,
+ ToolUseEndTime: &now,
+ },
+ },
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeUser,
+ LLMData: actualToolResult,
+ UsageData: llm.Usage{},
+ }); err != nil {
+ t.Fatalf("failed to create orphan tool_result message: %v", err)
+ }
+
+ // Now try to resume the conversation
+ // This should trigger the Anthropic API error if we don't fix the orphan tool_result
+ resumeReq := ChatRequest{
+ Message: "echo: continue",
+ Model: "predictable",
+ }
+ resumeBody, _ := json.Marshal(resumeReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Wait for the request to be processed
+ time.Sleep(300 * time.Millisecond)
+
+ // Check the last request sent to the LLM for orphan tool_results
+ lastRequest := predictableService.GetLastRequest()
+ if lastRequest == nil {
+ t.Fatal("no request was sent to the LLM")
+ }
+
+ // Check that orphan tool_results have been removed
+ // An orphan tool_result is one that references a tool_use_id that doesn't exist
+ // in the immediately preceding assistant message
+
+ var previousAssistantToolUses map[string]bool
+ for i, msg := range lastRequest.Messages {
+ if msg.Role == llm.MessageRoleAssistant {
+ // Track all tool_use IDs in this assistant message
+ previousAssistantToolUses = make(map[string]bool)
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ previousAssistantToolUses[content.ID] = true
+ }
+ }
+ } else if msg.Role == llm.MessageRoleUser {
+ // Check if any tool_results reference IDs not in previous assistant message
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ if previousAssistantToolUses != nil && !previousAssistantToolUses[content.ToolUseID] {
+ t.Errorf("BUG: Found orphan tool_result at message index %d with ToolUseID=%s that doesn't match any tool_use in the previous assistant message. "+
+ "This would cause Anthropic API error: 'Each tool_result block must have a corresponding tool_use block in the previous message'",
+ i, content.ToolUseID)
+ }
+ }
+ }
+ // Clear previousAssistantToolUses since user messages reset the expectation
+ previousAssistantToolUses = nil
+ }
+ }
+
+ t.Logf("LLM request has %d messages - test verified orphan tool_results are handled", len(lastRequest.Messages))
+}
+
+// TestOrphanToolResultFiltering tests that orphan tool_results are filtered out
+// even when they appear in the middle of the conversation
+func TestOrphanToolResultFiltering(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Create a conversation where there's an orphan tool_result in the middle
+ // followed by valid messages
+
+ // Message 1: User message
+ userMsg1 := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeUser,
+ LLMData: userMsg1,
+ }); err != nil {
+ t.Fatalf("failed to create message: %v", err)
+ }
+
+ // Message 2: Assistant response with end_of_turn (no tool_use)
+ assistantMsg := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hi there!"}},
+ EndOfTurn: true,
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeAgent,
+ LLMData: assistantMsg,
+ }); err != nil {
+ t.Fatalf("failed to create message: %v", err)
+ }
+
+ // Message 3: ORPHAN tool_result - previous assistant has no tool_use!
+ now := time.Now()
+ orphanResult := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {
+ Type: llm.ContentTypeToolResult,
+ ToolUseID: "toolu_orphan_xyz",
+ ToolError: false,
+ ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "orphan result"}},
+ ToolUseStartTime: &now,
+ ToolUseEndTime: &now,
+ },
+ },
+ }
+ if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: db.MessageTypeUser,
+ LLMData: orphanResult,
+ }); err != nil {
+ t.Fatalf("failed to create orphan message: %v", err)
+ }
+
+ // Now try to chat
+ chatReq := ChatRequest{
+ Message: "echo: test",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ time.Sleep(300 * time.Millisecond)
+
+ lastRequest := predictableService.GetLastRequest()
+ if lastRequest == nil {
+ t.Fatal("no request was sent to the LLM")
+ }
+
+ // Verify no orphan tool_results in the request
+ var prevToolUses map[string]bool
+ for i, msg := range lastRequest.Messages {
+ if msg.Role == llm.MessageRoleAssistant {
+ prevToolUses = make(map[string]bool)
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ prevToolUses[content.ID] = true
+ }
+ }
+ } else if msg.Role == llm.MessageRoleUser {
+ for _, content := range msg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ if prevToolUses != nil && !prevToolUses[content.ToolUseID] {
+ t.Errorf("BUG: Found orphan tool_result at message index %d", i)
+ }
+ }
+ }
+ prevToolUses = nil
+ }
+ }
+}
@@ -0,0 +1,799 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "tailscale.com/util/singleflight"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/models"
+ "shelley.exe.dev/ui"
+)
+
+// APIMessage is the message format sent to clients
+// TODO: We could maybe omit llm_data when display_data is available
+type APIMessage struct {
+ MessageID string `json:"message_id"`
+ ConversationID string `json:"conversation_id"`
+ SequenceID int64 `json:"sequence_id"`
+ Type string `json:"type"`
+ LlmData *string `json:"llm_data,omitempty"`
+ UserData *string `json:"user_data,omitempty"`
+ UsageData *string `json:"usage_data,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ DisplayData *string `json:"display_data,omitempty"`
+ EndOfTurn *bool `json:"end_of_turn,omitempty"`
+}
+
+// StreamResponse represents the response format for conversation streaming
+type StreamResponse struct {
+ Messages []APIMessage `json:"messages"`
+ Conversation generated.Conversation `json:"conversation"`
+ AgentWorking bool `json:"agent_working"`
+ ContextWindowSize uint64 `json:"context_window_size,omitempty"`
+}
+
+// LLMProvider is an interface for getting LLM services
+type LLMProvider interface {
+ GetService(modelID string) (llm.Service, error)
+ GetAvailableModels() []string
+ HasModel(modelID string) bool
+}
+
+// NewLLMServiceManager creates a new LLM service manager from config
+func NewLLMServiceManager(cfg *LLMConfig, history *models.LLMRequestHistory) LLMProvider {
+ // Convert LLMConfig to models.Config
+ modelConfig := &models.Config{
+ AnthropicAPIKey: cfg.AnthropicAPIKey,
+ OpenAIAPIKey: cfg.OpenAIAPIKey,
+ GeminiAPIKey: cfg.GeminiAPIKey,
+ FireworksAPIKey: cfg.FireworksAPIKey,
+ Gateway: cfg.Gateway,
+ Logger: cfg.Logger,
+ }
+
+ manager, err := models.NewManager(modelConfig, history)
+ if err != nil {
+ // This shouldn't happen in practice, but handle it gracefully
+ cfg.Logger.Error("Failed to create models manager", "error", err)
+ }
+
+ return manager
+}
+
+// toAPIMessages converts database messages to API messages.
+// When display_data is present (tool results), llm_data is omitted to save bandwidth
+// since the display_data contains all information needed for UI rendering.
+func toAPIMessages(messages []generated.Message) []APIMessage {
+ apiMessages := make([]APIMessage, len(messages))
+ for i, msg := range messages {
+ var endOfTurnPtr *bool
+ if msg.LlmData != nil && msg.Type == string(db.MessageTypeAgent) {
+ if endOfTurn, ok := extractEndOfTurn(*msg.LlmData); ok {
+ endOfTurnCopy := endOfTurn
+ endOfTurnPtr = &endOfTurnCopy
+ }
+ }
+
+ // TODO: Consider omitting llm_data when display_data is present to save bandwidth.
+ // The display_data contains all info needed for UI rendering of tool results,
+ // but the UI currently still uses llm_data for some checks.
+
+ apiMsg := APIMessage{
+ MessageID: msg.MessageID,
+ ConversationID: msg.ConversationID,
+ SequenceID: msg.SequenceID,
+ Type: msg.Type,
+ LlmData: msg.LlmData,
+ UserData: msg.UserData,
+ UsageData: msg.UsageData,
+ CreatedAt: msg.CreatedAt,
+ DisplayData: msg.DisplayData,
+ EndOfTurn: endOfTurnPtr,
+ }
+ apiMessages[i] = apiMsg
+ }
+ return apiMessages
+}
+
+func extractEndOfTurn(raw string) (bool, bool) {
+ var message llm.Message
+ if err := json.Unmarshal([]byte(raw), &message); err != nil {
+ return false, false
+ }
+ return message.EndOfTurn, true
+}
+
+// calculateContextWindowSize returns the context window usage from the most recent message.
+// Each API call's input tokens represent the full conversation history sent to the model,
+// so we only need the last message's tokens (not accumulated across all messages).
+// The total input includes regular input tokens plus cached tokens (both read and created).
+func calculateContextWindowSize(messages []APIMessage) uint64 {
+ // Find the last message with usage data
+ for i := len(messages) - 1; i >= 0; i-- {
+ msg := messages[i]
+ if msg.UsageData == nil {
+ continue
+ }
+ var usage llm.Usage
+ if err := json.Unmarshal([]byte(*msg.UsageData), &usage); err != nil {
+ continue
+ }
+ // Return total context window used: all input tokens + output tokens
+ // This represents the full context that would be sent for the next turn
+ return usage.ContextWindowUsed()
+ }
+ return 0
+}
+
+func agentWorking(messages []APIMessage) bool {
+ if len(messages) == 0 {
+ return false
+ }
+
+ last := messages[len(messages)-1]
+
+ // If the last message is an error, agent is not working
+ if last.Type == string(db.MessageTypeError) {
+ return false
+ }
+
+ if last.Type == string(db.MessageTypeAgent) {
+ if last.EndOfTurn == nil {
+ return true
+ }
+ return !*last.EndOfTurn
+ }
+
+ for i := len(messages) - 1; i >= 0; i-- {
+ msg := messages[i]
+ if msg.Type != string(db.MessageTypeAgent) {
+ continue
+ }
+ if msg.EndOfTurn == nil {
+ return true
+ }
+ if !*msg.EndOfTurn {
+ return true
+ }
+ // Agent ended turn, but newer non-agent messages exist, so agent is working again.
+ return true
+ }
+
+ // No agent message found yet but conversation has activity, assume agent is working.
+ return true
+}
+
+// isEndOfTurn checks if a database message represents end of turn
+func isEndOfTurn(msg *generated.Message) bool {
+ if msg == nil {
+ return false
+ }
+ // Error messages end the turn
+ if msg.Type == string(db.MessageTypeError) {
+ return true
+ }
+ // Only agent messages can have end_of_turn
+ if msg.Type != string(db.MessageTypeAgent) {
+ return false
+ }
+ if msg.LlmData == nil {
+ return false
+ }
+ endOfTurn, ok := extractEndOfTurn(*msg.LlmData)
+ if !ok {
+ return false
+ }
+ return endOfTurn
+}
+
+// calculateContextWindowSizeFromMsg calculates context window usage from a single message.
+// Returns 0 if the message has no usage data (e.g., user messages), in which case
+// the client should keep its previous context window value.
+func calculateContextWindowSizeFromMsg(msg *generated.Message) uint64 {
+ if msg == nil || msg.UsageData == nil {
+ return 0
+ }
+ var usage llm.Usage
+ if err := json.Unmarshal([]byte(*msg.UsageData), &usage); err != nil {
+ return 0
+ }
+ return usage.ContextWindowUsed()
+}
+
+// Server manages the HTTP API and active conversations
+type Server struct {
+ db *db.DB
+ llmManager LLMProvider
+ toolSetConfig claudetool.ToolSetConfig
+ activeConversations map[string]*ConversationManager
+ mu sync.Mutex
+ logger *slog.Logger
+ predictableOnly bool
+ terminalURL string
+ defaultModel string
+ links []Link
+ requireHeader string
+ conversationGroup singleflight.Group[string, *ConversationManager]
+}
+
+// NewServer creates a new server instance
+func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool.ToolSetConfig, logger *slog.Logger, predictableOnly bool, terminalURL, defaultModel, requireHeader string, links []Link) *Server {
+ return &Server{
+ db: database,
+ llmManager: llmManager,
+ toolSetConfig: toolSetConfig,
+ activeConversations: make(map[string]*ConversationManager),
+ logger: logger,
+ predictableOnly: predictableOnly,
+ terminalURL: terminalURL,
+ defaultModel: defaultModel,
+ requireHeader: requireHeader,
+ links: links,
+ }
+}
+
+// RegisterRoutes registers HTTP routes on the given mux
+func (s *Server) RegisterRoutes(mux *http.ServeMux) {
+ // API routes
+ mux.HandleFunc("/api/conversations", s.handleConversations)
+ mux.HandleFunc("/api/conversations/archived", s.handleArchivedConversations)
+ mux.HandleFunc("/api/conversations/new", s.handleNewConversation)
+ mux.HandleFunc("/api/conversation/", s.handleConversation)
+ mux.HandleFunc("/api/conversation-by-slug/", s.handleConversationBySlug)
+ mux.HandleFunc("/api/validate-cwd", s.handleValidateCwd)
+ mux.HandleFunc("/api/list-directory", s.handleListDirectory)
+ mux.HandleFunc("/api/git/diffs", s.handleGitDiffs)
+ mux.HandleFunc("/api/git/diffs/", s.handleGitDiffFiles)
+ mux.HandleFunc("/api/git/file-diff/", s.handleGitFileDiff)
+ mux.HandleFunc("/api/upload", s.handleUpload)
+
+ // Generic read route restricted to safe paths
+ mux.HandleFunc("/api/read", s.handleRead)
+ mux.HandleFunc("/api/write-file", s.handleWriteFile)
+
+ // Version endpoint
+ mux.HandleFunc("/version", s.handleVersion)
+
+ // Debug routes
+ mux.HandleFunc("/debug/llm", s.handleDebugLLM)
+
+ // Serve embedded UI assets with conservative caching
+ mux.Handle("/", s.staticHandler(ui.Assets()))
+}
+
+// handleValidateCwd validates that a path exists and is a directory
+func (s *Server) handleValidateCwd(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ path := r.URL.Query().Get("path")
+ if path == "" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": false,
+ "error": "path is required",
+ })
+ return
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ if os.IsNotExist(err) {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": false,
+ "error": "directory does not exist",
+ })
+ } else {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": false,
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+
+ if !info.IsDir() {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": false,
+ "error": "path is not a directory",
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": true,
+ })
+}
+
+// DirectoryEntry represents a single directory entry for the directory picker
+type DirectoryEntry struct {
+ Name string `json:"name"`
+ IsDir bool `json:"is_dir"`
+}
+
+// ListDirectoryResponse is the response from the list-directory endpoint
+type ListDirectoryResponse struct {
+ Path string `json:"path"`
+ Parent string `json:"parent"`
+ Entries []DirectoryEntry `json:"entries"`
+}
+
+// handleListDirectory lists the contents of a directory for the directory picker
+func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ path := r.URL.Query().Get("path")
+ if path == "" {
+ // Default to home directory or root
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ path = "/"
+ } else {
+ path = homeDir
+ }
+ }
+
+ // Clean and resolve the path
+ path = filepath.Clean(path)
+
+ // Verify path exists and is a directory
+ info, err := os.Stat(path)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ if os.IsNotExist(err) {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": "directory does not exist",
+ })
+ } else if os.IsPermission(err) {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": "permission denied",
+ })
+ } else {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+
+ if !info.IsDir() {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": "path is not a directory",
+ })
+ return
+ }
+
+ // Read directory contents
+ dirEntries, err := os.ReadDir(path)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ if os.IsPermission(err) {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": "permission denied",
+ })
+ } else {
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ return
+ }
+
+ // Build response with only directories (for directory picker)
+ var entries []DirectoryEntry
+ for _, entry := range dirEntries {
+ // Skip hidden files/directories (starting with .)
+ if strings.HasPrefix(entry.Name(), ".") {
+ continue
+ }
+ // Only include directories
+ if entry.IsDir() {
+ entries = append(entries, DirectoryEntry{
+ Name: entry.Name(),
+ IsDir: true,
+ })
+ }
+ }
+
+ // Calculate parent directory
+ parent := filepath.Dir(path)
+ if parent == path {
+ // At root, no parent
+ parent = ""
+ }
+
+ response := ListDirectoryResponse{
+ Path: path,
+ Parent: parent,
+ Entries: entries,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+}
+
+// getOrCreateConversationManager gets an existing conversation manager or creates a new one.
+func (s *Server) getOrCreateConversationManager(ctx context.Context, conversationID string) (*ConversationManager, error) {
+ manager, err, _ := s.conversationGroup.Do(conversationID, func() (*ConversationManager, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if manager, exists := s.activeConversations[conversationID]; exists {
+ manager.Touch()
+ return manager, nil
+ }
+
+ recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return s.recordMessage(ctx, conversationID, message, usage)
+ }
+
+ manager := NewConversationManager(conversationID, s.db, s.logger, s.toolSetConfig, recordMessage)
+ if err := manager.Hydrate(ctx); err != nil {
+ return nil, err
+ }
+
+ s.activeConversations[conversationID] = manager
+ return manager, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return manager, nil
+}
+
+// ExtractDisplayData extracts display data from message content for storage
+func ExtractDisplayData(message llm.Message) interface{} {
+ // Build a map of tool_use_id to tool_name for lookups
+ toolNameMap := make(map[string]string)
+ for _, content := range message.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ toolNameMap[content.ID] = content.ToolName
+ }
+ }
+
+ var displayData []any
+ for _, content := range message.Content {
+ if content.Type == llm.ContentTypeToolResult && content.Display != nil {
+ // Include tool name if we can find it
+ toolName := toolNameMap[content.ToolUseID]
+ displayData = append(displayData, map[string]any{
+ "tool_use_id": content.ToolUseID,
+ "tool_name": toolName,
+ "display": content.Display,
+ })
+ }
+ }
+
+ if len(displayData) > 0 {
+ return displayData
+ }
+ return nil
+}
+
+// recordMessage records a new message to the database and also notifies subscribers
+func (s *Server) recordMessage(ctx context.Context, conversationID string, message llm.Message, usage llm.Usage) error {
+ // Log message based on role
+ if message.Role == llm.MessageRoleUser {
+ s.logger.Info("User message", "conversation_id", conversationID, "content_items", len(message.Content))
+ } else if message.Role == llm.MessageRoleAssistant {
+ s.logger.Info("Agent message", "conversation_id", conversationID, "content_items", len(message.Content), "end_of_turn", message.EndOfTurn)
+ }
+
+ // Convert LLM message to database format
+ messageType, err := s.getMessageType(message)
+ if err != nil {
+ return fmt.Errorf("failed to determine message type: %w", err)
+ }
+
+ // Extract display data from content items
+ displayDataToStore := ExtractDisplayData(message)
+
+ // Create message
+ createdMsg, err := s.db.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: conversationID,
+ Type: messageType,
+ LLMData: message,
+ UserData: nil,
+ UsageData: usage,
+ DisplayData: displayDataToStore,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create message: %w", err)
+ }
+
+ // Update conversation's last updated timestamp for correct ordering
+ if err := s.db.QueriesTx(ctx, func(q *generated.Queries) error {
+ return q.UpdateConversationTimestamp(ctx, conversationID)
+ }); err != nil {
+ s.logger.Warn("Failed to update conversation timestamp", "conversationID", conversationID, "error", err)
+ }
+
+ // Touch active manager activity time if present
+ s.mu.Lock()
+ mgr, ok := s.activeConversations[conversationID]
+ if ok {
+ mgr.Touch()
+ }
+ s.mu.Unlock()
+
+ // Notify subscribers with only the new message - use WithoutCancel because
+ // the HTTP request context may be cancelled after the handler returns, but
+ // we still want the notification to complete so SSE clients see the message immediately
+ go s.notifySubscribersNewMessage(context.WithoutCancel(ctx), conversationID, createdMsg)
+
+ return nil
+}
+
+// getMessageType determines the message type from an LLM message
+func (s *Server) getMessageType(message llm.Message) (db.MessageType, error) {
+ switch message.Role {
+ case llm.MessageRoleUser:
+ return db.MessageTypeUser, nil
+ case llm.MessageRoleAssistant:
+ // Check if this is an error message by looking at content
+ for _, content := range message.Content {
+ if content.Type == llm.ContentTypeText && strings.HasPrefix(content.Text, "LLM request failed:") {
+ return db.MessageTypeError, nil
+ }
+ }
+ return db.MessageTypeAgent, nil
+ default:
+ // For tool messages, check if it's a tool call or tool result
+ for _, content := range message.Content {
+ if content.Type == llm.ContentTypeToolUse {
+ return db.MessageTypeTool, nil
+ }
+ if content.Type == llm.ContentTypeToolResult {
+ return db.MessageTypeTool, nil
+ }
+ }
+ return db.MessageTypeAgent, nil
+ }
+}
+
+// convertToLLMMessage converts a database message to an LLM message
+func convertToLLMMessage(msg generated.Message) (llm.Message, error) {
+ var llmMsg llm.Message
+ if msg.LlmData == nil {
+ return llm.Message{}, fmt.Errorf("message has no LLM data")
+ }
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ return llm.Message{}, fmt.Errorf("failed to unmarshal LLM data: %w", err)
+ }
+ return llmMsg, nil
+}
+
+// notifySubscribers sends conversation metadata updates (e.g., slug changes) to subscribers.
+// This is used when only the conversation data changes, not the messages.
+func (s *Server) notifySubscribers(ctx context.Context, conversationID string) {
+ s.mu.Lock()
+ manager, exists := s.activeConversations[conversationID]
+ s.mu.Unlock()
+
+ if !exists {
+ return
+ }
+
+ // Get conversation data only (no messages needed for metadata-only updates)
+ var conversation generated.Conversation
+ err := s.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ conversation, err = q.GetConversation(ctx, conversationID)
+ return err
+ })
+ if err != nil {
+ s.logger.Error("Failed to get conversation data for notification", "conversationID", conversationID, "error", err)
+ return
+ }
+
+ // For conversation-only updates, we need to get the latest sequence ID
+ // to properly notify subscribers, but we send an empty message list
+ var latestSequenceID int64
+ err = s.db.Queries(ctx, func(q *generated.Queries) error {
+ messages, err := q.ListMessages(ctx, conversationID)
+ if err != nil {
+ return err
+ }
+ if len(messages) > 0 {
+ latestSequenceID = messages[len(messages)-1].SequenceID
+ }
+ return nil
+ })
+ if err != nil {
+ s.logger.Error("Failed to get latest sequence ID", "conversationID", conversationID, "error", err)
+ return
+ }
+
+ // Publish conversation update with no new messages
+ streamData := StreamResponse{
+ Messages: nil, // No new messages, just conversation update
+ Conversation: conversation,
+ }
+ manager.subpub.Publish(latestSequenceID, streamData)
+}
+
+// notifySubscribersNewMessage sends a single new message to all subscribers.
+// This is more efficient than re-sending all messages on each update.
+func (s *Server) notifySubscribersNewMessage(ctx context.Context, conversationID string, newMsg *generated.Message) {
+ s.mu.Lock()
+ manager, exists := s.activeConversations[conversationID]
+ s.mu.Unlock()
+
+ if !exists {
+ return
+ }
+
+ // Get conversation data for the response
+ var conversation generated.Conversation
+ err := s.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ conversation, err = q.GetConversation(ctx, conversationID)
+ return err
+ })
+ if err != nil {
+ s.logger.Error("Failed to get conversation data for notification", "conversationID", conversationID, "error", err)
+ return
+ }
+
+ // Convert the single new message to API format
+ apiMessages := toAPIMessages([]generated.Message{*newMsg})
+
+ // Publish only the new message
+ streamData := StreamResponse{
+ Messages: apiMessages,
+ Conversation: conversation,
+ AgentWorking: !isEndOfTurn(newMsg),
+ // ContextWindowSize: 0 for messages without usage data (user/tool messages).
+ // With omitempty, 0 is omitted from JSON, so the UI keeps its cached value.
+ // Only agent messages have usage data, so context window updates when they arrive.
+ ContextWindowSize: calculateContextWindowSizeFromMsg(newMsg),
+ }
+ manager.subpub.Publish(newMsg.SequenceID, streamData)
+}
+
+// Cleanup removes inactive conversation managers
+func (s *Server) Cleanup() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := time.Now()
+ for id, manager := range s.activeConversations {
+ // Remove managers that have been inactive for more than 30 minutes
+ manager.mu.Lock()
+ lastActivity := manager.lastActivity
+ manager.mu.Unlock()
+ if now.Sub(lastActivity) > 30*time.Minute {
+ manager.stopLoop()
+ delete(s.activeConversations, id)
+ s.logger.Debug("Cleaned up inactive conversation", "conversationID", id)
+ }
+ }
+}
+
+// Start starts the HTTP server and handles the complete lifecycle
+func (s *Server) Start(port string) error {
+ listener, err := net.Listen("tcp", ":"+port)
+ if err != nil {
+ s.logger.Error("Failed to create listener", "error", err, "port_info", getPortOwnerInfo(port))
+ return err
+ }
+ return s.StartWithListener(listener)
+}
+
+// StartWithListener starts the HTTP server using the provided listener.
+// This is useful for systemd socket activation where the listener is created externally.
+func (s *Server) StartWithListener(listener net.Listener) error {
+ // Set up HTTP server with routes and middleware
+ mux := http.NewServeMux()
+ s.RegisterRoutes(mux)
+
+ // Add middleware (applied in reverse order: last added = first executed)
+ handler := LoggerMiddleware(s.logger)(mux)
+ handler = CSRFMiddleware()(handler)
+ if s.requireHeader != "" {
+ handler = RequireHeaderMiddleware(s.requireHeader)(handler)
+ }
+
+ httpServer := &http.Server{
+ Handler: handler,
+ }
+
+ // Start cleanup routine
+ go func() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ s.Cleanup()
+ }
+ }()
+
+ // Get actual port from listener
+ actualPort := listener.Addr().(*net.TCPAddr).Port
+
+ // Start server in goroutine
+ serverErrCh := make(chan error, 1)
+ go func() {
+ s.logger.Info("Server starting", "port", actualPort, "url", fmt.Sprintf("http://localhost:%d", actualPort))
+ if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
+ serverErrCh <- err
+ }
+ }()
+
+ // Wait for shutdown signal or server error
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ select {
+ case err := <-serverErrCh:
+ s.logger.Error("Server failed", "error", err)
+ return err
+ case <-quit:
+ s.logger.Info("Shutting down server")
+ }
+
+ // Graceful shutdown
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ if err := httpServer.Shutdown(ctx); err != nil {
+ s.logger.Error("Server forced to shutdown", "error", err)
+ return err
+ }
+
+ s.logger.Info("Server exited")
+ return nil
+}
+
+// getPortOwnerInfo tries to identify what process is using a port.
+// Returns a human-readable string with the PID and process name, or an error message.
+func getPortOwnerInfo(port string) string {
+ // Use lsof to find the process using the port
+ cmd := exec.Command("lsof", "-i", ":"+port, "-sTCP:LISTEN", "-n", "-P")
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Sprintf("(unable to determine: %v)", err)
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ if len(lines) < 2 {
+ return "(no process found)"
+ }
+
+ // Parse lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
+ // Skip the header line
+ for _, line := range lines[1:] {
+ fields := strings.Fields(line)
+ if len(fields) >= 2 {
+ command := fields[0]
+ pid := fields[1]
+ return fmt.Sprintf("pid=%s process=%s", pid, command)
+ }
+ }
+
+ return "(could not parse lsof output)"
+}
@@ -0,0 +1,395 @@
+package server
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// flusherRecorder wraps httptest.ResponseRecorder to implement http.Flusher
+// and provide immediate access to written data
+type flusherRecorder struct {
+ *httptest.ResponseRecorder
+ mu sync.Mutex
+ chunks []string
+ flushed chan struct{}
+}
+
+func newFlusherRecorder() *flusherRecorder {
+ return &flusherRecorder{
+ ResponseRecorder: httptest.NewRecorder(),
+ flushed: make(chan struct{}, 100),
+ }
+}
+
+func (f *flusherRecorder) Flush() {
+ f.mu.Lock()
+ body := f.Body.String()
+ f.chunks = append(f.chunks, body)
+ f.mu.Unlock()
+
+ select {
+ case f.flushed <- struct{}{}:
+ default:
+ }
+}
+
+func (f *flusherRecorder) getChunks() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ result := make([]string, len(f.chunks))
+ copy(result, f.chunks)
+ return result
+}
+
+// TestSSEUserMessageAppearsImmediately tests that when a user sends a message,
+// the message appears in the SSE stream immediately, before the LLM responds.
+func TestSSEUserMessageAppearsImmediately(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Set up a context we can cancel to stop the SSE handler
+ sseCtx, sseCancel := context.WithCancel(context.Background())
+ defer sseCancel()
+
+ // Start the SSE stream handler in a goroutine
+ sseRecorder := newFlusherRecorder()
+ sseReq := httptest.NewRequest("GET", "/api/conversation/"+conversationID+"/stream", nil)
+ sseReq = sseReq.WithContext(sseCtx)
+
+ sseStarted := make(chan struct{})
+ sseDone := make(chan struct{})
+ go func() {
+ close(sseStarted)
+ server.handleStreamConversation(sseRecorder, sseReq, conversationID)
+ close(sseDone)
+ }()
+
+ // Wait for SSE handler to start and send initial state
+ <-sseStarted
+
+ // Wait for the initial SSE event (empty messages)
+ select {
+ case <-sseRecorder.flushed:
+ // Got initial state
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for initial SSE event")
+ }
+
+ // Now send a user message that triggers a SLOW LLM response (3 seconds delay)
+ chatReq := ChatRequest{
+ Message: "delay: 3",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // The user message should appear in the SSE stream IMMEDIATELY (within 500ms)
+ // NOT after the 3 second LLM delay
+ deadline := time.Now().Add(500 * time.Millisecond)
+ userMessageFound := false
+
+ for time.Now().Before(deadline) {
+ select {
+ case <-sseRecorder.flushed:
+ // Check if user message is now in the stream
+ body := sseRecorder.Body.String()
+ if containsUserMessage(body, "delay: 3") {
+ userMessageFound = true
+ }
+ case <-time.After(50 * time.Millisecond):
+ // Also check current body
+ body := sseRecorder.Body.String()
+ if containsUserMessage(body, "delay: 3") {
+ userMessageFound = true
+ }
+ }
+ if userMessageFound {
+ break
+ }
+ }
+
+ if !userMessageFound {
+ t.Errorf("BUG: user message did not appear in SSE stream within 500ms (LLM has 3s delay)")
+ t.Log("This likely means notifySubscribers is not being called immediately after recording the user message")
+ t.Logf("SSE body so far: %s", sseRecorder.Body.String())
+ } else {
+ t.Log("SUCCESS: user message appeared in SSE stream immediately")
+ }
+
+ // Clean up: cancel SSE context and wait for handler to finish
+ sseCancel()
+ select {
+ case <-sseDone:
+ case <-time.After(1 * time.Second):
+ // Handler may not exit immediately, that's OK
+ }
+}
+
+// containsUserMessage checks if the SSE body contains a user message with the given text
+func containsUserMessage(sseBody, messageText string) bool {
+ // SSE format is "data: {json}\n\n"
+ scanner := bufio.NewScanner(strings.NewReader(sseBody))
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ jsonStr := strings.TrimPrefix(line, "data: ")
+
+ var streamResp StreamResponse
+ if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+ continue
+ }
+
+ for _, msg := range streamResp.Messages {
+ if msg.Type != string(db.MessageTypeUser) {
+ continue
+ }
+ if msg.LlmData == nil {
+ continue
+ }
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, messageText) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+// TestSSEUserMessageWithRealHTTPServer tests with a real HTTP server to properly
+// test HTTP context cancellation behavior
+func TestSSEUserMessageWithRealHTTPServer(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ srv := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Set up real HTTP server
+ mux := http.NewServeMux()
+ srv.RegisterRoutes(mux)
+ httpServer := httptest.NewServer(mux)
+ defer httpServer.Close()
+
+ // Connect to SSE stream
+ sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+ if err != nil {
+ t.Fatalf("failed to connect to SSE stream: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ // Start reading SSE events in background
+ sseEvents := make(chan string, 100)
+ go func() {
+ scanner := bufio.NewScanner(sseResp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.HasPrefix(line, "data: ") {
+ sseEvents <- line
+ }
+ }
+ }()
+
+ // Wait for initial SSE event
+ select {
+ case <-sseEvents:
+ // Got initial state
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for initial SSE event")
+ }
+
+ // Send user message with slow LLM response via real HTTP client
+ chatReq := ChatRequest{
+ Message: "delay: 5",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+ "application/json",
+ strings.NewReader(string(chatBody)),
+ )
+ if err != nil {
+ t.Fatalf("failed to send chat message: %v", err)
+ }
+ resp.Body.Close()
+
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d", resp.StatusCode)
+ }
+
+ // User message should appear in SSE stream within 500ms (before 5s LLM delay)
+ deadline := time.Now().Add(500 * time.Millisecond)
+ userMessageFound := false
+
+ for time.Now().Before(deadline) && !userMessageFound {
+ select {
+ case eventLine := <-sseEvents:
+ jsonStr := strings.TrimPrefix(eventLine, "data: ")
+ var streamResp StreamResponse
+ if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+ continue
+ }
+ for _, msg := range streamResp.Messages {
+ if msg.Type == string(db.MessageTypeUser) && msg.LlmData != nil {
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "delay: 5") {
+ userMessageFound = true
+ break
+ }
+ }
+ }
+ }
+ }
+ case <-time.After(50 * time.Millisecond):
+ // Keep waiting
+ }
+ }
+
+ if !userMessageFound {
+ t.Error("BUG: user message did not appear in SSE stream within 500ms with real HTTP server")
+ t.Log("This confirms the context cancellation bug in notifySubscribers")
+ } else {
+ t.Log("SUCCESS: user message appeared in SSE stream immediately with real HTTP server")
+ }
+}
+
+// TestSSEUserMessageWithExistingConnection is a simpler version that tests
+// message recording and notification without the SSE complexity
+func TestSSEUserMessageWithExistingConnection(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create conversation and get a manager (simulating an established SSE connection)
+ conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("failed to create conversation: %v", err)
+ }
+ conversationID := conversation.ConversationID
+
+ // Get the conversation manager to set up subscription
+ manager, err := server.getOrCreateConversationManager(context.Background(), conversationID)
+ if err != nil {
+ t.Fatalf("failed to get conversation manager: %v", err)
+ }
+
+ // Subscribe to updates
+ subCtx, subCancel := context.WithCancel(context.Background())
+ defer subCancel()
+ next := manager.subpub.Subscribe(subCtx, -1)
+
+ // Channel to receive updates
+ updates := make(chan StreamResponse, 10)
+ go func() {
+ for {
+ data, ok := next()
+ if !ok {
+ return
+ }
+ updates <- data
+ }
+ }()
+
+ // Now send a user message with slow LLM response
+ chatReq := ChatRequest{
+ Message: "delay: 5",
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleChatConversation(w, req, conversationID)
+ if w.Code != http.StatusAccepted {
+ t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // We should receive an update with the user message within 500ms
+ // (well before the 5 second LLM delay)
+ select {
+ case update := <-updates:
+ // Check that the update contains the user message
+ foundUserMsg := false
+ for _, msg := range update.Messages {
+ if msg.Type == string(db.MessageTypeUser) && msg.LlmData != nil {
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "delay: 5") {
+ foundUserMsg = true
+ break
+ }
+ }
+ }
+ }
+ }
+ if !foundUserMsg {
+ t.Error("received update but it didn't contain the user message")
+ t.Logf("update had %d messages", len(update.Messages))
+ } else {
+ t.Log("SUCCESS: received user message via subpub immediately")
+ }
+ case <-time.After(500 * time.Millisecond):
+ t.Error("BUG: did not receive subpub update with user message within 500ms")
+ t.Log("This means notifySubscribers is failing or not being called after user message is recorded")
+ }
+}
@@ -0,0 +1,287 @@
+package server
+
+import (
+ _ "embed"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "text/template"
+)
+
+//go:embed system_prompt.txt
+var systemPromptTemplate string
+
+// SystemPromptData contains all the data needed to render the system prompt template
+type SystemPromptData struct {
+ WorkingDirectory string
+ GitInfo *GitInfo
+ Codebase *CodebaseInfo
+ IsExeDev bool
+ IsSudoAvailable bool
+ Hostname string // For exe.dev, the public hostname (e.g., "vmname.exe.xyz")
+ ShelleyDBPath string // Path to the shelley database
+}
+
+// DBPath is the path to the shelley database, set at startup
+var DBPath string
+
+type GitInfo struct {
+ Root string
+}
+
+type CodebaseInfo struct {
+ InjectFiles []string
+ InjectFileContents map[string]string
+ GuidanceFiles []string
+}
+
+// GenerateSystemPrompt generates the system prompt using the embedded template.
+// If workingDir is empty, it uses the current working directory.
+func GenerateSystemPrompt(workingDir string) (string, error) {
+ data, err := collectSystemData(workingDir)
+ if err != nil {
+ return "", fmt.Errorf("failed to collect system data: %w", err)
+ }
+
+ tmpl, err := template.New("system_prompt").Parse(systemPromptTemplate)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ var buf strings.Builder
+ err = tmpl.Execute(&buf, data)
+ if err != nil {
+ return "", fmt.Errorf("failed to execute template: %w", err)
+ }
+
+ return buf.String(), nil
+}
+
+func collectSystemData(workingDir string) (*SystemPromptData, error) {
+ wd := workingDir
+ if wd == "" {
+ var err error
+ wd, err = os.Getwd()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get working directory: %w", err)
+ }
+ }
+
+ data := &SystemPromptData{
+ WorkingDirectory: wd,
+ }
+
+ // Try to collect git info
+ gitInfo, err := collectGitInfo()
+ if err == nil {
+ data.GitInfo = gitInfo
+ }
+
+ // Collect codebase info
+ codebaseInfo, err := collectCodebaseInfo(wd, gitInfo)
+ if err == nil {
+ data.Codebase = codebaseInfo
+ }
+
+ // Check if running on exe.dev
+ data.IsExeDev = isExeDev()
+
+ // Check sudo availability
+ data.IsSudoAvailable = isSudoAvailable()
+
+ // Get hostname for exe.dev
+ if data.IsExeDev {
+ if hostname, err := os.Hostname(); err == nil {
+ // If hostname doesn't contain dots, add .exe.xyz suffix
+ if !strings.Contains(hostname, ".") {
+ hostname = hostname + ".exe.xyz"
+ }
+ data.Hostname = hostname
+ }
+ }
+
+ // Set shelley database path if it was configured
+ if DBPath != "" {
+ // Convert to absolute path if relative
+ if !filepath.IsAbs(DBPath) {
+ if absPath, err := filepath.Abs(DBPath); err == nil {
+ data.ShelleyDBPath = absPath
+ } else {
+ data.ShelleyDBPath = DBPath
+ }
+ } else {
+ data.ShelleyDBPath = DBPath
+ }
+ }
+
+ return data, nil
+}
+
+func collectGitInfo() (*GitInfo, error) {
+ // Find git root
+ rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
+ rootOutput, err := rootCmd.Output()
+ if err != nil {
+ return nil, err
+ }
+ root := strings.TrimSpace(string(rootOutput))
+
+ return &GitInfo{
+ Root: root,
+ }, nil
+}
+
+func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
+ info := &CodebaseInfo{
+ InjectFiles: []string{},
+ InjectFileContents: make(map[string]string),
+ GuidanceFiles: []string{},
+ }
+
+ // Track seen files to avoid duplicates on case-insensitive file systems
+ seenFiles := make(map[string]bool)
+
+ // Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
+ if home, err := os.UserHomeDir(); err == nil {
+ // Prefer ~/.config/shelley/AGENTS.md (XDG convention)
+ configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
+ if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
+ info.InjectFiles = append(info.InjectFiles, configAgentsFile)
+ info.InjectFileContents[configAgentsFile] = string(content)
+ seenFiles[strings.ToLower(configAgentsFile)] = true
+ }
+ // Also check legacy ~/.shelley/AGENTS.md location
+ shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
+ if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
+ lowerPath := strings.ToLower(shelleyAgentsFile)
+ if !seenFiles[lowerPath] {
+ info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
+ info.InjectFileContents[shelleyAgentsFile] = string(content)
+ seenFiles[lowerPath] = true
+ }
+ }
+ }
+
+ // Determine the root directory to search
+ searchRoot := wd
+ if gitInfo != nil {
+ searchRoot = gitInfo.Root
+ }
+
+ // Find root-level guidance files (case-insensitive)
+ rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
+ for _, file := range rootGuidanceFiles {
+ lowerPath := strings.ToLower(file)
+ if seenFiles[lowerPath] {
+ continue
+ }
+ seenFiles[lowerPath] = true
+
+ content, err := os.ReadFile(file)
+ if err == nil && len(content) > 0 {
+ info.InjectFiles = append(info.InjectFiles, file)
+ info.InjectFileContents[file] = string(content)
+ }
+ }
+
+ // If working directory is different from root, also check working directory
+ if wd != searchRoot {
+ wdGuidanceFiles := findGuidanceFilesInDir(wd)
+ for _, file := range wdGuidanceFiles {
+ lowerPath := strings.ToLower(file)
+ if seenFiles[lowerPath] {
+ continue
+ }
+ seenFiles[lowerPath] = true
+
+ content, err := os.ReadFile(file)
+ if err == nil && len(content) > 0 {
+ info.InjectFiles = append(info.InjectFiles, file)
+ info.InjectFileContents[file] = string(content)
+ }
+ }
+ }
+
+ // Find all guidance files recursively for the directory listing
+ allGuidanceFiles := findAllGuidanceFiles(searchRoot)
+ info.GuidanceFiles = allGuidanceFiles
+
+ return info, nil
+}
+
+func findGuidanceFilesInDir(dir string) []string {
+ // Read directory entries to handle case-insensitive file systems
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+
+ guidanceNames := map[string]bool{
+ "agent.md": true,
+ "claude.md": true,
+ "dear_llm.md": true,
+ "readme.md": true,
+ }
+
+ var found []string
+ seen := make(map[string]bool)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ lowerName := strings.ToLower(entry.Name())
+ if guidanceNames[lowerName] && !seen[lowerName] {
+ seen[lowerName] = true
+ found = append(found, filepath.Join(dir, entry.Name()))
+ }
+ }
+ return found
+}
+
+func findAllGuidanceFiles(root string) []string {
+ guidanceNames := map[string]bool{
+ "agent.md": true,
+ "claude.md": true,
+ "dear_llm.md": true,
+ }
+
+ var found []string
+ seen := make(map[string]bool)
+
+ filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Continue on errors
+ }
+ if info.IsDir() {
+ // Skip hidden directories and common ignore patterns
+ if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ lowerName := strings.ToLower(info.Name())
+ if guidanceNames[lowerName] {
+ lowerPath := strings.ToLower(path)
+ if !seen[lowerPath] {
+ seen[lowerPath] = true
+ found = append(found, path)
+ }
+ }
+ return nil
+ })
+ return found
+}
+
+func isExeDev() bool {
+ _, err := os.Stat("/exe.dev")
+ return err == nil
+}
+
+func isSudoAvailable() bool {
+ cmd := exec.Command("sudo", "-n", "id")
+ _, err := cmd.CombinedOutput()
+ return err == nil
+}
@@ -0,0 +1,78 @@
+You are Shelley, a coding agent and assistant. You are an experienced software engineer and architect. You communicate with brevity.
+
+You have access to a variety of tools to get your job done. Be persistent and creative.
+
+Working directory: {{.WorkingDirectory}}
+
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+
+If you are making code changes, make commits with good commit messages before returning to the user.
+{{else}}Not in a git repository.
+{{end}}
+{{if .IsExeDev}}
+<exe_dev>
+You are running on a VM in the exe.dev hosting service. If you run an HTTP service on localhost on ports 3000-9999, the user can see that on https://{{.Hostname}}:<port>/.
+Port 8000 is a good default choice. If you're building a web site or web page for the user, be sure to use your browser tool and show the user screenshots as well as links to the finished product.
+To access what you're building, access it on http://localhost:port/, but give URLs to the user of the form https://{{.Hostname}}:port/
+
+For exe.dev documentation, retrieve https://exe.dev/docs/all.md
+
+{{if .IsSudoAvailable}}<sudo_access>available</sudo_access>{{else}}<sudo_access>not_available</sudo_access>{{end}}
+
+<systemd>
+To run a service persistently, install a systemd unit file. Example for a service binary at /home/exedev/srv:
+
+ sudo cp srv.service /etc/systemd/system/srv.service
+ sudo systemctl daemon-reload
+ sudo systemctl enable srv.service
+ sudo systemctl start srv
+
+Manage with: systemctl status srv, systemctl restart srv, journalctl -u srv -f
+</systemd>
+
+<project_templates>
+If the user wants to create a new Go web application or service, you can use the "go" project template as a starting point. Run:
+ mkdir -p /path/to/project && shelley unpack-template go /path/to/project
+This provides a complete Go web server with HTTP handlers, SQLite database, migrations, and systemd service configuration. After unpacking, initialize a git repository with `git init` and make an initial commit.
+</project_templates>
+</exe_dev>
+{{end}}
+{{if .Codebase}}
+<customization>
+Guidance files (dear_llm.md, agent.md, claude.md) contain project information and direct user instructions.
+Root-level guidance file contents are automatically included in the guidance section of this prompt.
+Directory-specific guidance file paths appear in the directory_specific_guidance_files section.
+Before modifying any file, you MUST proactively read and follow all guidance files in its directory and all parent directories.
+When guidance files conflict, more-deeply-nested files take precedence.
+Direct user instructions from the current conversation always take highest precedence.
+</customization>
+{{if .Codebase.InjectFiles}}
+<guidance>
+{{range .Codebase.InjectFiles}}<root_guidance file="{{.}}">
+{{index $.Codebase.InjectFileContents .}}
+</root_guidance>
+{{end}}</guidance>
+{{end}}
+{{if .Codebase.GuidanceFiles}}
+<directory_specific_guidance_files>
+{{range .Codebase.GuidanceFiles}}{{.}}
+{{end}}</directory_specific_guidance_files>
+{{end}}
+{{end}}
+{{if .ShelleyDBPath}}
+<previous_conversations>
+Your conversation history is stored in a SQLite database at: {{.ShelleyDBPath}}
+
+If the user wants to refer to a previous conversation, you can read it using sqlite3:
+
+# List recent conversations:
+sqlite3 "{{.ShelleyDBPath}}" "SELECT conversation_id, slug, datetime(created_at, 'localtime') as created, datetime(updated_at, 'localtime') as updated FROM conversations ORDER BY updated_at DESC LIMIT 20;"
+
+# Get messages from a specific conversation (replace CONVERSATION_ID):
+sqlite3 "{{.ShelleyDBPath}}" "SELECT type, CASE WHEN type='user' THEN json_extract(user_data, '$.text') ELSE substr(llm_data, 1, 500) END as content FROM messages WHERE conversation_id='CONVERSATION_ID' ORDER BY sequence_id;"
+
+# Search conversations by slug:
+sqlite3 "{{.ShelleyDBPath}}" "SELECT conversation_id, slug FROM conversations WHERE slug LIKE '%SEARCH_TERM%';"
+</previous_conversations>
+{{end}}
@@ -0,0 +1,251 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// TestHarness provides a DSL-like interface for testing conversations.
+type TestHarness struct {
+ t *testing.T
+ db *db.DB
+ server *Server
+ cleanup func()
+ llm *loop.PredictableService
+ convID string
+ timeout time.Duration
+ responsesCount int // Number of agent responses seen so far
+}
+
+// NewTestHarness creates a new test harness with a predictable LLM and bash tool.
+func NewTestHarness(t *testing.T) *TestHarness {
+ t.Helper()
+
+ database, cleanup := setupTestDB(t)
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+ toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+ server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+ return &TestHarness{
+ t: t,
+ db: database,
+ server: server,
+ cleanup: cleanup,
+ llm: predictableService,
+ timeout: 5 * time.Second,
+ }
+}
+
+// Close cleans up the test harness resources.
+func (h *TestHarness) Close() {
+ h.cleanup()
+}
+
+// NewConversation starts a new conversation with the given message and options.
+func (h *TestHarness) NewConversation(msg, cwd string) *TestHarness {
+ h.t.Helper()
+
+ chatReq := ChatRequest{
+ Message: msg,
+ Model: "predictable",
+ Cwd: cwd,
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversations/new", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ h.server.handleNewConversation(w, req)
+ if w.Code != http.StatusCreated {
+ h.t.Fatalf("NewConversation: expected status 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp struct {
+ ConversationID string `json:"conversation_id"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ h.t.Fatalf("NewConversation: failed to parse response: %v", err)
+ }
+ h.convID = resp.ConversationID
+ return h
+}
+
+// Chat sends a message to the current conversation.
+func (h *TestHarness) Chat(msg string) *TestHarness {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("Chat: no conversation started, call NewConversation first")
+ }
+
+ chatReq := ChatRequest{
+ Message: msg,
+ Model: "predictable",
+ }
+ chatBody, _ := json.Marshal(chatReq)
+
+ req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/chat", strings.NewReader(string(chatBody)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ h.server.handleChatConversation(w, req, h.convID)
+ if w.Code != http.StatusAccepted {
+ h.t.Fatalf("Chat: expected status 202, got %d: %s", w.Code, w.Body.String())
+ }
+ return h
+}
+
+// WaitToolResult waits for a tool result and returns its text content.
+func (h *TestHarness) WaitToolResult() string {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("WaitToolResult: no conversation started")
+ }
+
+ deadline := time.Now().Add(h.timeout)
+ for time.Now().Before(deadline) {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ h.t.Fatalf("WaitToolResult: failed to get messages: %v", err)
+ }
+
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeToolResult {
+ for _, result := range content.ToolResult {
+ if result.Type == llm.ContentTypeText && result.Text != "" {
+ return result.Text
+ }
+ }
+ }
+ }
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ h.t.Fatalf("WaitToolResult: timed out waiting for tool result")
+ return ""
+}
+
+// WaitResponse waits for the assistant's text response (end of turn).
+// It waits for a NEW response that hasn't been seen before.
+func (h *TestHarness) WaitResponse() string {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("WaitResponse: no conversation started")
+ }
+
+ targetCount := h.responsesCount + 1
+
+ deadline := time.Now().Add(h.timeout)
+ for time.Now().Before(deadline) {
+ var messages []generated.Message
+ err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+ var qerr error
+ messages, qerr = q.ListMessages(context.Background(), h.convID)
+ return qerr
+ })
+ if err != nil {
+ h.t.Fatalf("WaitResponse: failed to get messages: %v", err)
+ }
+
+ // Count assistant messages with end_of_turn
+ count := 0
+ var lastText string
+ for _, msg := range messages {
+ if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+ continue
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ continue
+ }
+
+ if llmMsg.EndOfTurn {
+ count++
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText {
+ lastText = content.Text
+ break
+ }
+ }
+ }
+ }
+
+ if count >= targetCount {
+ h.responsesCount = count
+ return lastText
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ h.t.Fatalf("WaitResponse: timed out waiting for response (seen %d, need %d)", h.responsesCount, targetCount)
+ return ""
+}
+
+// ConversationID returns the current conversation ID.
+func (h *TestHarness) ConversationID() string {
+ return h.convID
+}
+
+// GetContextWindowSize retrieves the current context window size from the server.
+func (h *TestHarness) GetContextWindowSize() uint64 {
+ h.t.Helper()
+
+ if h.convID == "" {
+ h.t.Fatal("GetContextWindowSize: no conversation started")
+ }
+
+ // Use handleGetConversation (GET /conversation/<id>) instead of stream endpoint
+ req := httptest.NewRequest("GET", "/api/conversation/"+h.convID, nil)
+ w := httptest.NewRecorder()
+
+ h.server.handleGetConversation(w, req, h.convID)
+ if w.Code != http.StatusOK {
+ h.t.Fatalf("GetContextWindowSize: expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp StreamResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ h.t.Fatalf("GetContextWindowSize: failed to parse response: %v", err)
+ }
+
+ return resp.ContextWindowSize
+}
@@ -0,0 +1,264 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/claudetool/browse"
+ "shelley.exe.dev/loop"
+)
+
+func TestUploadEndpoint(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create a multipart form with a file
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ // Create a test file
+ part, err := writer.CreateFormFile("file", "test.png")
+ if err != nil {
+ t.Fatalf("failed to create form file: %v", err)
+ }
+
+ // Write some fake PNG content (just the magic header bytes)
+ pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ if _, err := part.Write(pngData); err != nil {
+ t.Fatalf("failed to write file content: %v", err)
+ }
+ writer.Close()
+
+ req := httptest.NewRequest("POST", "/api/upload", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+
+ server.handleUpload(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var response map[string]string
+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ path, ok := response["path"]
+ if !ok {
+ t.Fatal("response missing 'path' field")
+ }
+
+ // Verify the path is in the screenshot directory
+ if !strings.HasPrefix(path, browse.ScreenshotDir) {
+ t.Errorf("expected path to start with %s, got %s", browse.ScreenshotDir, path)
+ }
+
+ // Verify the file has the correct extension
+ if !strings.HasSuffix(path, ".png") {
+ t.Errorf("expected path to end with .png, got %s", path)
+ }
+
+ // Verify the file exists and contains our data
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("failed to read uploaded file: %v", err)
+ }
+
+ if !bytes.Equal(data, pngData) {
+ t.Errorf("uploaded file content mismatch")
+ }
+
+ // Clean up uploaded file
+ os.Remove(path)
+}
+
+func TestUploadEndpointMethodNotAllowed(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ req := httptest.NewRequest("GET", "/api/upload", nil)
+ w := httptest.NewRecorder()
+
+ server.handleUpload(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("expected status 405, got %d", w.Code)
+ }
+}
+
+func TestUploadEndpointNoFile(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // Create an empty multipart form
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ writer.Close()
+
+ req := httptest.NewRequest("POST", "/api/upload", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+
+ server.handleUpload(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUploadedFileCanBeReadViaReadEndpoint(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ // First, upload a file
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ part, err := writer.CreateFormFile("file", "test.jpg")
+ if err != nil {
+ t.Fatalf("failed to create form file: %v", err)
+ }
+
+ // Write some fake JPEG content
+ jpgData := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46}
+ if _, err := part.Write(jpgData); err != nil {
+ t.Fatalf("failed to write file content: %v", err)
+ }
+ writer.Close()
+
+ uploadReq := httptest.NewRequest("POST", "/api/upload", body)
+ uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
+ uploadW := httptest.NewRecorder()
+
+ server.handleUpload(uploadW, uploadReq)
+
+ if uploadW.Code != http.StatusOK {
+ t.Fatalf("upload failed: %s", uploadW.Body.String())
+ }
+
+ var uploadResponse map[string]string
+ if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse); err != nil {
+ t.Fatalf("failed to parse upload response: %v", err)
+ }
+
+ path := uploadResponse["path"]
+
+ // Now try to read the file via the read endpoint
+ readReq := httptest.NewRequest("GET", "/api/read?path="+path, nil)
+ readW := httptest.NewRecorder()
+
+ server.handleRead(readW, readReq)
+
+ if readW.Code != http.StatusOK {
+ t.Fatalf("read failed with status %d: %s", readW.Code, readW.Body.String())
+ }
+
+ // Verify content type
+ contentType := readW.Header().Get("Content-Type")
+ if contentType != "image/jpeg" {
+ t.Errorf("expected Content-Type image/jpeg, got %s", contentType)
+ }
+
+ // Verify content
+ readData, err := io.ReadAll(readW.Body)
+ if err != nil {
+ t.Fatalf("failed to read response body: %v", err)
+ }
+
+ if !bytes.Equal(readData, jpgData) {
+ t.Errorf("read content mismatch")
+ }
+
+ // Clean up
+ os.Remove(path)
+}
+
+func TestUploadPreservesFileExtension(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ logger := slog.Default()
+ server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+ testCases := []struct {
+ filename string
+ wantExt string
+ }{
+ {"photo.png", ".png"},
+ {"image.jpeg", ".jpeg"},
+ {"screenshot.gif", ".gif"},
+ {"document.pdf", ".pdf"},
+ {"noextension", ""},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.filename, func(t *testing.T) {
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ part, err := writer.CreateFormFile("file", tc.filename)
+ if err != nil {
+ t.Fatalf("failed to create form file: %v", err)
+ }
+ part.Write([]byte("test content"))
+ writer.Close()
+
+ req := httptest.NewRequest("POST", "/api/upload", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+
+ server.handleUpload(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var response map[string]string
+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ path := response["path"]
+ ext := filepath.Ext(path)
+ if ext != tc.wantExt {
+ t.Errorf("expected extension %q, got %q", tc.wantExt, ext)
+ }
+
+ // Clean up
+ os.Remove(path)
+ })
+ }
+}
@@ -0,0 +1,167 @@
+package slug
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "regexp"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+)
+
+// LLMServiceProvider defines the interface for getting LLM services
+type LLMServiceProvider interface {
+ GetService(modelID string) (llm.Service, error)
+}
+
+// GenerateSlug generates a slug for a conversation and updates the database
+// If conversationModelID is provided, it will try to use that model first before falling back to the default list
+func GenerateSlug(ctx context.Context, llmProvider LLMServiceProvider, database *db.DB, logger *slog.Logger, conversationID, userMessage, conversationModelID string) (string, error) {
+ baseSlug, err := generateSlugText(ctx, llmProvider, logger, userMessage, conversationModelID)
+ if err != nil {
+ return "", err
+ }
+
+ // Try to update with the base slug first, then with numeric suffixes if needed
+ slug := baseSlug
+ for attempt := 0; attempt < 100; attempt++ {
+ _, err = database.UpdateConversationSlug(ctx, conversationID, slug)
+ if err == nil {
+ // Success!
+ logger.Info("Generated slug for conversation", "conversationID", conversationID, "slug", slug)
+ return slug, nil
+ }
+
+ // Check if this is a unique constraint violation
+ if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") ||
+ strings.Contains(strings.ToLower(err.Error()), "unique constraint") ||
+ strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+ // Try with a numeric suffix
+ slug = fmt.Sprintf("%s-%d", baseSlug, attempt+1)
+ continue
+ }
+
+ // Some other error occurred
+ return "", fmt.Errorf("failed to update conversation slug: %w", err)
+ }
+
+ // If we've tried 100 times and still failed, give up
+ return "", fmt.Errorf("failed to generate unique slug after 100 attempts")
+}
+
+// generateSlugText generates a human-readable slug for a conversation based on the user message
+// If conversationModelID is "predictable", it will be used instead of the default preferred models
+func generateSlugText(ctx context.Context, llmProvider LLMServiceProvider, logger *slog.Logger, userMessage, conversationModelID string) (string, error) {
+ // Try different models in order of preference
+ var llmService llm.Service
+ var err error
+
+ // Preferred models in order of preference
+ preferredModels := []string{"qwen3-coder-fireworks", "gpt5-mini", "gpt-5-thinking-mini", "claude-sonnet-4.5", "predictable"}
+
+ // If conversation is using predictable model, use it for slug generation too
+ if conversationModelID == "predictable" {
+ llmService, err = llmProvider.GetService("predictable")
+ if err == nil {
+ logger.Debug("Using predictable model for slug generation")
+ } else {
+ logger.Debug("Predictable model not available for slug generation", "error", err)
+ }
+ }
+
+ // If we didn't get the predictable service, try the preferred models
+ if llmService == nil {
+ for _, model := range preferredModels {
+ llmService, err = llmProvider.GetService(model)
+ if err == nil {
+ logger.Debug("Using preferred model for slug generation", "model", model)
+ break
+ }
+ logger.Debug("Model not available for slug generation", "model", model, "error", err)
+ }
+ }
+
+ if llmService == nil {
+ return "", fmt.Errorf("no suitable model available for slug generation")
+ }
+
+ // Create a focused prompt for slug generation
+ slugPrompt := fmt.Sprintf(`Generate a short, descriptive slug (2-6 words, lowercase, hyphen-separated) for a conversation that starts with this user message:
+
+%s
+
+The slug should:
+- Be concise and descriptive
+- Use only lowercase letters, numbers, and hyphens
+- Capture the main topic or intent
+- Be suitable as a filename or URL path
+
+Respond with only the slug, nothing else.`, userMessage)
+
+ message := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: slugPrompt},
+ },
+ }
+
+ request := &llm.Request{
+ Messages: []llm.Message{message},
+ }
+
+ // Make LLM request with timeout
+ ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ response, err := llmService.Do(ctxWithTimeout, request)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate slug: %w", err)
+ }
+
+ // Extract text from response
+ if len(response.Content) == 0 {
+ return "", fmt.Errorf("empty response from LLM")
+ }
+
+ slug := strings.TrimSpace(response.Content[0].Text)
+
+ // Clean and validate the slug
+ slug = Sanitize(slug)
+ if slug == "" {
+ return "", fmt.Errorf("generated slug is empty after sanitization")
+ }
+
+ // Note: We don't check for uniqueness here since we're generating for a new conversation
+ // and the database will handle any conflicts
+
+ return slug, nil
+}
+
+// Sanitize cleans a string to be a valid slug
+func Sanitize(input string) string {
+ // Convert to lowercase
+ slug := strings.ToLower(input)
+
+ // Replace spaces and underscores with hyphens
+ slug = regexp.MustCompile(`[\s_]+`).ReplaceAllString(slug, "-")
+
+ // Remove non-alphanumeric characters except hyphens
+ slug = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(slug, "")
+
+ // Remove multiple consecutive hyphens
+ slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
+
+ // Remove leading/trailing hyphens
+ slug = strings.Trim(slug, "-")
+
+ // Limit length
+ if len(slug) > 60 {
+ slug = slug[:60]
+ slug = strings.Trim(slug, "-")
+ }
+
+ return slug
+}
@@ -0,0 +1,178 @@
+package slug
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "testing"
+
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+)
+
+func TestSanitize(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"Simple Test", "simple-test"},
+ {"Create a Python Script", "create-a-python-script"},
+ {"Multiple Spaces", "multiple-spaces"},
+ {"Special@#$%Characters", "specialcharacters"},
+ {"Under_Score_Test", "under-score-test"},
+ {"--multiple-hyphens--", "multiple-hyphens"},
+ {"CamelCase Example", "camelcase-example"},
+ {"123 Numbers Test 456", "123-numbers-test-456"},
+ {" leading and trailing ", "leading-and-trailing"},
+ {"", ""},
+ {"Very Long Slug That Might Need To Be Truncated Because It Is Too Long For Normal Use", "very-long-slug-that-might-need-to-be-truncated-because-it-is"},
+ }
+
+ for _, test := range tests {
+ result := Sanitize(test.input)
+ if result != test.expected {
+ t.Errorf("Sanitize(%q) = %q, expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+// TestGenerateUniqueSlug tests that slug generation adds numeric suffixes when there are conflicts
+func TestGenerateSlug_UniquenessSuffix(t *testing.T) {
+ // This test verifies the numeric suffix logic without needing a real database or LLM
+ // We'll test the error handling and retry logic by mocking the behavior
+
+ // Test the sanitization works as expected first
+ baseSlug := Sanitize("Test Message")
+ expected := "test-message"
+ if baseSlug != expected {
+ t.Errorf("Sanitize failed: got %q, expected %q", baseSlug, expected)
+ }
+
+ // Test that numeric suffixes would be correctly formatted
+ // This mimics what the GenerateSlug function does internally
+ tests := []struct {
+ baseSlug string
+ attempt int
+ expected string
+ }{
+ {"test-message", 0, "test-message-1"},
+ {"test-message", 1, "test-message-2"},
+ {"test-message", 2, "test-message-3"},
+ {"help-python", 9, "help-python-10"},
+ }
+
+ for _, test := range tests {
+ result := fmt.Sprintf("%s-%d", test.baseSlug, test.attempt+1)
+ if result != test.expected {
+ t.Errorf("Suffix generation failed: got %q, expected %q", result, test.expected)
+ }
+ }
+}
+
+// MockLLMService provides a mock LLM service for testing
+type MockLLMService struct {
+ ResponseText string
+}
+
+func (m *MockLLMService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
+ return &llm.Response{
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: m.ResponseText},
+ },
+ }, nil
+}
+
+func (m *MockLLMService) TokenContextWindow() int {
+ return 8192 // Mock token limit
+}
+
+// MockLLMProvider provides a mock LLM provider for testing
+type MockLLMProvider struct {
+ Service *MockLLMService
+}
+
+func (m *MockLLMProvider) GetService(modelID string) (llm.Service, error) {
+ return m.Service, nil
+}
+
+// TestGenerateSlug_DatabaseIntegration tests slug generation with actual database conflicts
+func TestGenerateSlug_DatabaseIntegration(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/slug_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ ctx := context.Background()
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create mock LLM provider that always returns the same slug
+ mockLLM := &MockLLMProvider{
+ Service: &MockLLMService{
+ ResponseText: "test-slug", // Always return the same slug to force conflicts
+ },
+ }
+
+ // Create logger (silent for tests)
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: slog.LevelWarn, // Only show warnings and errors
+ }))
+
+ // Create first conversation to establish the base slug
+ conv1, err := database.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create first conversation: %v", err)
+ }
+
+ // Generate first slug - should succeed with "test-slug"
+ slug1, err := GenerateSlug(ctx, mockLLM, database, logger, conv1.ConversationID, "Test message", "")
+ if err != nil {
+ t.Fatalf("Failed to generate first slug: %v", err)
+ }
+ if slug1 != "test-slug" {
+ t.Errorf("Expected first slug to be 'test-slug', got %q", slug1)
+ }
+
+ // Create second conversation
+ conv2, err := database.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create second conversation: %v", err)
+ }
+
+ // Generate second slug - should get "test-slug-1" due to conflict
+ slug2, err := GenerateSlug(ctx, mockLLM, database, logger, conv2.ConversationID, "Test message", "")
+ if err != nil {
+ t.Fatalf("Failed to generate second slug: %v", err)
+ }
+ if slug2 != "test-slug-1" {
+ t.Errorf("Expected second slug to be 'test-slug-1', got %q", slug2)
+ }
+
+ // Create third conversation
+ conv3, err := database.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create third conversation: %v", err)
+ }
+
+ // Generate third slug - should get "test-slug-2" due to conflict
+ slug3, err := GenerateSlug(ctx, mockLLM, database, logger, conv3.ConversationID, "Test message", "")
+ if err != nil {
+ t.Fatalf("Failed to generate third slug: %v", err)
+ }
+ if slug3 != "test-slug-2" {
+ t.Errorf("Expected third slug to be 'test-slug-2', got %q", slug3)
+ }
+
+ // Verify all slugs are different
+ if slug1 == slug2 || slug1 == slug3 || slug2 == slug3 {
+ t.Errorf("All slugs should be unique: slug1=%q, slug2=%q, slug3=%q", slug1, slug2, slug3)
+ }
+
+ t.Logf("Successfully generated unique slugs: %q, %q, %q", slug1, slug2, slug3)
+}
@@ -0,0 +1,13 @@
+version: "2"
+sql:
+ - engine: "sqlite"
+ queries: "db/query/"
+ schema: "db/schema/"
+ gen:
+ go:
+ package: "generated"
+ out: "db/generated/"
+ emit_json_tags: true
+ emit_empty_slices: true
+ emit_pointers_for_null_types: true
+ json_tags_case_style: "snake"
@@ -0,0 +1,108 @@
+package subpub
+
+import (
+ "context"
+ "sync"
+)
+
+type SubPub[K any] struct {
+ mu sync.Mutex
+ subscribers []*subscriber[K]
+}
+
+type subscriber[K any] struct {
+ idx int64
+ ch chan K
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func New[K any]() *SubPub[K] {
+ return &SubPub[K]{
+ subscribers: make([]*subscriber[K], 0),
+ }
+}
+
+// Subscribe registers an interest in messages after the given index, subject to the
+// expiration/cancellation of the provided context. The returned function blocks
+// until a new message, and can return false as the second arguent if the subscription
+// is done for.
+func (sp *SubPub[K]) Subscribe(ctx context.Context, idx int64) func() (K, bool) {
+ // Create a child context so we can cancel the subscription independently
+ subCtx, cancel := context.WithCancel(ctx)
+
+ // Buffered channel to avoid blocking publishers
+ ch := make(chan K, 10)
+ sub := &subscriber[K]{
+ idx: idx,
+ ch: ch,
+ ctx: subCtx,
+ cancel: cancel,
+ }
+
+ sp.mu.Lock()
+ sp.subscribers = append(sp.subscribers, sub)
+ sp.mu.Unlock()
+
+ // Return a function that blocks until the next message
+ return func() (K, bool) {
+ select {
+ case msg, ok := <-ch:
+ if !ok {
+ var zero K
+ return zero, false
+ }
+ return msg, true
+ case <-subCtx.Done():
+ // Context cancelled, but drain any buffered messages first
+ select {
+ case msg, ok := <-ch:
+ if ok {
+ return msg, true
+ }
+ default:
+ }
+ var zero K
+ return zero, false
+ }
+ }
+}
+
+// Publish sends a message to all subscribers waiting for messages after the given index.
+// Subscribers that are "behind" should get a disconnection message.
+func (sp *SubPub[K]) Publish(idx int64, message K) {
+ sp.mu.Lock()
+ defer sp.mu.Unlock()
+
+ // Notify subscribers and filter out disconnected ones
+ remaining := sp.subscribers[:0]
+ for _, sub := range sp.subscribers {
+ // Check if context is still valid
+ select {
+ case <-sub.ctx.Done():
+ // Context cancelled, close channel and don't keep subscriber
+ close(sub.ch)
+ continue
+ default:
+ }
+
+ // Only send to subscribers waiting for messages after an index < idx
+ if sub.idx < idx {
+ // Try to send the message
+ select {
+ case sub.ch <- message:
+ // Success, update subscriber's index and keep them
+ sub.idx = idx
+ remaining = append(remaining, sub)
+ default:
+ // Channel full, subscriber is behind - disconnect them
+ close(sub.ch)
+ sub.cancel()
+ }
+ } else {
+ // This subscriber is not interested yet (already has this index or beyond)
+ remaining = append(remaining, sub)
+ }
+ }
+ sp.subscribers = remaining
+}
@@ -0,0 +1,262 @@
+package subpub
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "testing/synctest"
+ "time"
+)
+
+func TestSubPubBasic(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[string]()
+ ctx := context.Background()
+
+ // Subscribe waiting for messages after index 0
+ next := sp.Subscribe(ctx, 0)
+
+ // Publish a message at index 1
+ go func() {
+ sp.Publish(1, "hello")
+ }()
+
+ // Should receive the message
+ msg, ok := next()
+ if !ok {
+ t.Fatal("Expected to receive message, got closed channel")
+ }
+ if msg != "hello" {
+ t.Errorf("Expected 'hello', got %q", msg)
+ }
+ })
+}
+
+func TestSubPubMultipleSubscribers(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[string]()
+ ctx := context.Background()
+
+ // Create multiple subscribers
+ next1 := sp.Subscribe(ctx, 0)
+ next2 := sp.Subscribe(ctx, 0)
+ next3 := sp.Subscribe(ctx, 0)
+
+ // Publish a message
+ go func() {
+ sp.Publish(1, "broadcast")
+ }()
+
+ // All subscribers should receive it
+ for i, next := range []func() (string, bool){next1, next2, next3} {
+ msg, ok := next()
+ if !ok {
+ t.Fatalf("Subscriber %d: expected to receive message, got closed channel", i+1)
+ }
+ if msg != "broadcast" {
+ t.Errorf("Subscriber %d: expected 'broadcast', got %q", i+1, msg)
+ }
+ }
+ })
+}
+
+func TestSubPubSubscriberAlreadyHasMessage(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[int]()
+ ctx := context.Background()
+
+ // Subscriber already has index 5, waiting for index > 5
+ next := sp.Subscribe(ctx, 5)
+
+ // Publish at index 3 (subscriber already has this)
+ sp.Publish(3, 100)
+
+ // Publish at index 6 (subscriber should get this)
+ go func() {
+ sp.Publish(6, 200)
+ }()
+
+ msg, ok := next()
+ if !ok {
+ t.Fatal("Expected to receive message, got closed channel")
+ }
+ if msg != 200 {
+ t.Errorf("Expected 200, got %d", msg)
+ }
+ })
+}
+
+func TestSubPubContextCancellation(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[string]()
+ ctx, cancel := context.WithCancel(context.Background())
+
+ next := sp.Subscribe(ctx, 0)
+
+ // Cancel the context
+ cancel()
+
+ // Should return false when context is cancelled
+ _, ok := next()
+ if ok {
+ t.Error("Expected closed channel after context cancellation")
+ }
+ })
+}
+
+func TestSubPubSubscriberBehind(t *testing.T) {
+ // Don't use synctest for this test as it involves checking buffer overflow behavior
+ sp := New[string]()
+ ctx := context.Background()
+
+ // Subscriber waiting for messages after index 0
+ next := sp.Subscribe(ctx, 0)
+
+ // Fill up the channel buffer (10 messages) quickly before subscriber reads
+ for i := 1; i <= 10; i++ {
+ sp.Publish(int64(i), fmt.Sprintf("message%d", i))
+ }
+
+ // Try to send one more - subscriber should be disconnected because buffer is full
+ sp.Publish(11, "overflow")
+
+ // Try to receive - should work for buffered messages
+ received := 0
+ var messages []string
+ for {
+ msg, ok := next()
+ if !ok {
+ break
+ }
+ messages = append(messages, msg)
+ received++
+ if received > 11 {
+ t.Fatal("Received more messages than expected")
+ }
+ }
+
+ // Should have received exactly 10 messages before being disconnected
+ if received != 10 {
+ t.Errorf("Expected to receive 10 buffered messages, got %d: %v", received, messages)
+ }
+}
+
+func TestSubPubSequentialMessages(t *testing.T) {
+ // Don't use synctest for this test as mutex blocking doesn't work well with it
+ sp := New[int]()
+ ctx := context.Background()
+
+ next := sp.Subscribe(ctx, 0)
+
+ // Publish multiple messages in order
+ for i := 1; i <= 5; i++ {
+ sp.Publish(int64(i), i*10)
+ }
+
+ // Receive all messages
+ received := []int{}
+ for i := 1; i <= 5; i++ {
+ msg, ok := next()
+ if !ok {
+ t.Fatalf("Expected to receive 5 messages, got closed channel after %d messages", i-1)
+ }
+ received = append(received, msg)
+ }
+
+ // Check we got all expected values in order
+ expected := []int{10, 20, 30, 40, 50}
+ for i, val := range received {
+ if val != expected[i] {
+ t.Errorf("Message %d: expected %d, got %d", i, expected[i], val)
+ }
+ }
+}
+
+func TestSubPubLateSubscriber(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[string]()
+ ctx := context.Background()
+
+ // Publish some messages before anyone subscribes
+ sp.Publish(1, "early1")
+ sp.Publish(2, "early2")
+
+ // Late subscriber joins, interested in messages after index 2
+ next := sp.Subscribe(ctx, 2)
+
+ // Publish a new message
+ go func() {
+ sp.Publish(3, "late")
+ }()
+
+ // Should only receive the new message
+ msg, ok := next()
+ if !ok {
+ t.Fatal("Expected to receive message, got closed channel")
+ }
+ if msg != "late" {
+ t.Errorf("Expected 'late', got %q", msg)
+ }
+ })
+}
+
+func TestSubPubWithTimeout(t *testing.T) {
+ sp := New[string]()
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ next := sp.Subscribe(ctx, 0)
+
+ // Don't publish anything, just wait for timeout
+ _, ok := next()
+ if ok {
+ t.Error("Expected timeout to close the subscription")
+ }
+}
+
+func TestSubPubMultiplePublishes(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ sp := New[string]()
+ ctx := context.Background()
+
+ // Start two subscribers at different positions
+ next1 := sp.Subscribe(ctx, 0)
+ next2 := sp.Subscribe(ctx, 1)
+
+ // Publish at index 2 - only next1 should receive (next2 already has idx 1)
+ go func() {
+ sp.Publish(2, "msg2")
+ }()
+
+ msg, ok := next1()
+ if !ok {
+ t.Fatal("Subscriber 1: expected to receive message, got closed channel")
+ }
+ if msg != "msg2" {
+ t.Errorf("Subscriber 1: expected 'msg2', got %q", msg)
+ }
+
+ msg, ok = next2()
+ if !ok {
+ t.Fatal("Subscriber 2: expected to receive message, got closed channel")
+ }
+ if msg != "msg2" {
+ t.Errorf("Subscriber 2: expected 'msg2', got %q", msg)
+ }
+
+ // Now both are at index 2, publish at index 3
+ go func() {
+ sp.Publish(3, "msg3")
+ }()
+
+ for i, next := range []func() (string, bool){next1, next2} {
+ msg, ok := next()
+ if !ok {
+ t.Fatalf("Subscriber %d: expected to receive msg3, got closed channel", i+1)
+ }
+ if msg != "msg3" {
+ t.Errorf("Subscriber %d: expected 'msg3', got %q", i+1, msg)
+ }
+ }
+ })
+}
@@ -0,0 +1,5 @@
+# Agent Instructions
+
+This is a Go web application template for exe.dev.
+
+See README.md for details on the structure and components.
@@ -0,0 +1,10 @@
+.PHONY: build clean stop start restart test
+
+build:
+ go build -o srv ./cmd/srv
+
+clean:
+ rm -f srv
+
+test:
+ go test ./...
@@ -0,0 +1,57 @@
+# Go Shelley Template
+
+This is a starter template for building Go web applications on exe.dev. It demonstrates end-to-end usage including HTTP handlers, authentication, database integration, and deployment.
+
+Use this as a foundation to build your own service.
+
+## Building and Running
+
+Build with `make build`, then run `./srv`. The server listens on port 8000 by default.
+
+## Running as a systemd service
+
+To run the server as a systemd service:
+
+```bash
+# Install the service file
+sudo cp srv.service /etc/systemd/system/srv.service
+
+# Reload systemd and enable the service
+sudo systemctl daemon-reload
+sudo systemctl enable srv.service
+
+# Start the service
+sudo systemctl start srv
+
+# Check status
+systemctl status srv
+
+# View logs
+journalctl -u srv -f
+```
+
+To restart after code changes:
+
+```bash
+make build
+sudo systemctl restart srv
+```
+
+## Authorization
+
+exe.dev provides authorization headers and login/logout links
+that this template uses.
+
+When proxied through exed, requests will include `X-ExeDev-UserID` and
+`X-ExeDev-Email` if the user is authenticated via exe.dev.
+
+## Database
+
+This template uses sqlite (`db.sqlite3`). SQL queries are managed with sqlc.
+
+## Code layout
+
+- `cmd/srv`: main package (binary entrypoint)
+- `srv`: HTTP server logic (handlers)
+- `srv/templates`: Go HTML templates
+- `db`: SQLite open + migrations (001-base.sql)
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "srv.exe.dev/srv"
+)
+
+var flagListenAddr = flag.String("listen", ":8000", "address to listen on")
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ }
+}
+
+func run() error {
+ flag.Parse()
+ hostname, err := os.Hostname()
+ if err != nil {
+ hostname = "unknown"
+ }
+ server, err := srv.New("db.sqlite3", hostname)
+ if err != nil {
+ return fmt.Errorf("create server: %w", err)
+ }
+ return server.Serve(*flagListenAddr)
+}
@@ -0,0 +1,115 @@
+package db
+
+import (
+ "database/sql"
+ "embed"
+ "errors"
+ "fmt"
+ "log/slog"
+ "regexp"
+ "sort"
+ "strconv"
+
+ _ "modernc.org/sqlite"
+)
+
+//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Open opens an sqlite database and prepares pragmas suitable for a small web app.
+func Open(path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // Light pragmas similar
+ if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("enable foreign keys: %w", err)
+ }
+ if _, err := db.Exec("PRAGMA journal_mode=wal;"); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("set WAL: %w", err)
+ }
+ if _, err := db.Exec("PRAGMA busy_timeout=1000;"); err != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("set busy_timeout: %w", err)
+ }
+ return db, nil
+}
+
+// RunMigrations executes database migrations in numeric order (NNN-*.sql),
+// similar in spirit to exed's exedb.RunMigrations.
+func RunMigrations(db *sql.DB) error {
+ entries, err := migrationFS.ReadDir("migrations")
+ if err != nil {
+ return fmt.Errorf("read migrations dir: %w", err)
+ }
+ var migrations []string
+ pat := regexp.MustCompile(`^(\d{3})-.*\.sql$`)
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if pat.MatchString(name) {
+ migrations = append(migrations, name)
+ }
+ }
+ sort.Strings(migrations)
+
+ executed := make(map[int]bool)
+ var tableName string
+ err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&tableName)
+ switch {
+ case err == nil:
+ rows, err := db.Query("SELECT migration_number FROM migrations")
+ if err != nil {
+ return fmt.Errorf("query executed migrations: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var n int
+ if err := rows.Scan(&n); err != nil {
+ return fmt.Errorf("scan migration number: %w", err)
+ }
+ executed[n] = true
+ }
+ case errors.Is(err, sql.ErrNoRows):
+ slog.Info("db: migrations table not found; running all migrations")
+ default:
+ return fmt.Errorf("check migrations table: %w", err)
+ }
+
+ for _, m := range migrations {
+ match := pat.FindStringSubmatch(m)
+ if len(match) != 2 {
+ return fmt.Errorf("invalid migration filename: %s", m)
+ }
+ n, err := strconv.Atoi(match[1])
+ if err != nil {
+ return fmt.Errorf("parse migration number %s: %w", m, err)
+ }
+ if executed[n] {
+ continue
+ }
+ if err := executeMigration(db, m); err != nil {
+ return fmt.Errorf("execute %s: %w", m, err)
+ }
+ slog.Info("db: applied migration", "file", m, "number", n)
+ }
+ return nil
+}
+
+func executeMigration(db *sql.DB, filename string) error {
+ content, err := migrationFS.ReadFile("migrations/" + filename)
+ if err != nil {
+ return fmt.Errorf("read %s: %w", filename, err)
+ }
+ if _, err := db.Exec(string(content)); err != nil {
+ return fmt.Errorf("exec %s: %w", filename, err)
+ }
+ return nil
+}
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package dbgen
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
@@ -0,0 +1,22 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package dbgen
+
+import (
+ "time"
+)
+
+type Migration struct {
+ MigrationNumber int64 `json:"migration_number"`
+ MigrationName string `json:"migration_name"`
+ ExecutedAt time.Time `json:"executed_at"`
+}
+
+type Visitor struct {
+ ID string `json:"id"`
+ ViewCount int64 `json:"view_count"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeen time.Time `json:"last_seen"`
+}
@@ -0,0 +1,54 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: visitors.sql
+
+package dbgen
+
+import (
+ "context"
+ "time"
+)
+
+const upsertVisitor = `-- name: UpsertVisitor :exec
+INSERT INTO
+ visitors (id, view_count, created_at, last_seen)
+VALUES
+ (?, 1, ?, ?) ON CONFLICT (id) DO
+UPDATE
+SET
+ view_count = view_count + 1,
+ last_seen = excluded.last_seen
+`
+
+type UpsertVisitorParams struct {
+ ID string `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeen time.Time `json:"last_seen"`
+}
+
+func (q *Queries) UpsertVisitor(ctx context.Context, arg UpsertVisitorParams) error {
+ _, err := q.db.ExecContext(ctx, upsertVisitor, arg.ID, arg.CreatedAt, arg.LastSeen)
+ return err
+}
+
+const visitorWithID = `-- name: VisitorWithID :one
+SELECT
+ id, view_count, created_at, last_seen
+FROM
+ visitors
+WHERE
+ id = ?
+`
+
+func (q *Queries) VisitorWithID(ctx context.Context, id string) (Visitor, error) {
+ row := q.db.QueryRowContext(ctx, visitorWithID, id)
+ var i Visitor
+ err := row.Scan(
+ &i.ID,
+ &i.ViewCount,
+ &i.CreatedAt,
+ &i.LastSeen,
+ )
+ return i, err
+}
@@ -0,0 +1,22 @@
+-- Base schema
+--
+-- Migrations tracking table
+CREATE TABLE IF NOT EXISTS migrations (
+ migration_number INTEGER PRIMARY KEY,
+ migration_name TEXT NOT NULL,
+ executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Visitors table
+CREATE TABLE IF NOT EXISTS visitors (
+ id TEXT PRIMARY KEY,
+ view_count INTEGER NOT NULL,
+ created_at TIMESTAMP NOT NULL,
+ last_seen TIMESTAMP NOT NULL
+);
+
+-- Record execution of this migration
+INSERT
+OR IGNORE INTO migrations (migration_number, migration_name)
+VALUES
+ (001, '001-base');
@@ -0,0 +1,17 @@
+-- name: UpsertVisitor :exec
+INSERT INTO
+ visitors (id, view_count, created_at, last_seen)
+VALUES
+ (?, 1, ?, ?) ON CONFLICT (id) DO
+UPDATE
+SET
+ view_count = view_count + 1,
+ last_seen = excluded.last_seen;
+
+-- name: VisitorWithID :one
+SELECT
+ *
+FROM
+ visitors
+WHERE
+ id = ?;
@@ -0,0 +1,14 @@
+version: "2"
+sql:
+ - engine: "sqlite"
+ queries: "queries/"
+ schema: "migrations/"
+ gen:
+ go:
+ package: "dbgen"
+ out: "dbgen/"
+ emit_json_tags: true
+ emit_empty_slices: true
+ emit_pointers_for_null_types: true
+ json_tags_case_style: "snake"
+ sql_package: "database/sql"
@@ -0,0 +1,60 @@
+module srv.exe.dev
+
+go 1.25.5
+
+require modernc.org/sqlite v1.39.0
+
+require (
+ cel.dev/expr v0.24.0 // indirect
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/cubicdaiya/gonp v1.0.4 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fatih/structtag v1.2.0 // indirect
+ github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/google/cel-go v0.26.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.7.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
+ github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
+ github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
+ github.com/pingcap/log v1.1.0 // indirect
+ github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/riza-io/grpc-go v0.2.0 // indirect
+ github.com/spf13/cobra v1.9.1 // indirect
+ github.com/spf13/pflag v1.0.7 // indirect
+ github.com/sqlc-dev/sqlc v1.30.0 // indirect
+ github.com/stoewer/go-strcase v1.2.0 // indirect
+ github.com/tetratelabs/wazero v1.9.0 // indirect
+ github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
+ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ golang.org/x/crypto v0.39.0 // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+ golang.org/x/net v0.41.0 // indirect
+ golang.org/x/sync v0.16.0 // indirect
+ golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/text v0.26.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+ google.golang.org/grpc v1.75.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/libc v1.66.3 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
+
+tool github.com/sqlc-dev/sqlc/cmd/sqlc
@@ -0,0 +1,209 @@
+cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
+cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
+github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
+github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
+github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
+github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
+github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
+github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
+github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
+github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
+github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
+modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,18 @@
+[Unit]
+Description=Go web server
+
+[Service]
+Type=simple
+User=exedev
+Group=exedev
+WorkingDirectory=/home/exedev
+ExecStart=/home/exedev/srv
+Restart=always
+RestartSec=5
+Environment=HOME=/home/exedev
+Environment=USER=exedev
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
@@ -0,0 +1,187 @@
+package srv
+
+import (
+ "database/sql"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ "srv.exe.dev/db"
+ "srv.exe.dev/db/dbgen"
+)
+
+type Server struct {
+ DB *sql.DB
+ Hostname string
+ TemplatesDir string
+ StaticDir string
+}
+
+type pageData struct {
+ Hostname string
+ Now string
+ UserEmail string
+ VisitCount int64
+ LoginURL string
+ LogoutURL string
+ Headers []headerEntry
+}
+
+type headerEntry struct {
+ Name string
+ Values []string
+ AddedByExe bool
+}
+
+func New(dbPath, hostname string) (*Server, error) {
+ _, thisFile, _, _ := runtime.Caller(0)
+ baseDir := filepath.Dir(thisFile)
+ srv := &Server{
+ Hostname: hostname,
+ TemplatesDir: filepath.Join(baseDir, "templates"),
+ StaticDir: filepath.Join(baseDir, "static"),
+ }
+ if err := srv.setUpDatabase(dbPath); err != nil {
+ return nil, err
+ }
+ return srv, nil
+}
+
+func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
+ // Identity from proxy headers (if present)
+ // UserID is stable; email is useful.
+ userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID"))
+ userEmail := strings.TrimSpace(r.Header.Get("X-ExeDev-Email"))
+ now := time.Now()
+
+ var count int64
+ if userID != "" && s.DB != nil {
+ q := dbgen.New(s.DB)
+ shouldRecordView := r.Method == http.MethodGet
+ if shouldRecordView {
+ // Best effort
+ err := q.UpsertVisitor(r.Context(), dbgen.UpsertVisitorParams{
+ ID: userID,
+ CreatedAt: now,
+ LastSeen: now,
+ })
+ if err != nil {
+ slog.Warn("upsert visitor", "error", err, "user_id", userID)
+ }
+ }
+ if v, err := q.VisitorWithID(r.Context(), userID); err == nil {
+ count = v.ViewCount
+ }
+ }
+
+ data := pageData{
+ Hostname: s.Hostname,
+ Now: now.Format(time.RFC3339),
+ UserEmail: userEmail,
+ VisitCount: count,
+ LoginURL: loginURLForRequest(r),
+ LogoutURL: "/__exe.dev/logout",
+ Headers: buildHeaderEntries(r),
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := s.renderTemplate(w, "welcome.html", data); err != nil {
+ slog.Warn("render template", "url", r.URL.Path, "error", err)
+ }
+}
+
+func loginURLForRequest(r *http.Request) string {
+ path := r.URL.RequestURI()
+ v := url.Values{}
+ v.Set("redirect", path)
+ return "/__exe.dev/login?" + v.Encode()
+}
+
+func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error {
+ path := filepath.Join(s.TemplatesDir, name)
+ tmpl, err := template.ParseFiles(path)
+ if err != nil {
+ return fmt.Errorf("parse template %q: %w", name, err)
+ }
+ if err := tmpl.Execute(w, data); err != nil {
+ return fmt.Errorf("execute template %q: %w", name, err)
+ }
+ return nil
+}
+
+func mainDomainFromHost(h string) string {
+ host, port, err := net.SplitHostPort(h)
+ if err != nil {
+ host = strings.TrimSpace(h)
+ }
+ if port != "" {
+ port = ":" + port
+ }
+ // Check for exe.cloud-based domains (dev mode)
+ if strings.HasSuffix(host, ".exe.cloud") || host == "exe.cloud" {
+ return "exe.cloud" + port
+ }
+ // Check for exe.dev-based domains (production)
+ if strings.HasSuffix(host, ".exe.dev") || host == "exe.dev" {
+ return "exe.dev"
+ }
+ // Return as-is for custom domains
+ return host
+}
+
+// SetupDatabase initializes the database connection and runs migrations
+func (s *Server) setUpDatabase(dbPath string) error {
+ wdb, err := db.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to open db: %w", err)
+ }
+ s.DB = wdb
+ if err := db.RunMigrations(wdb); err != nil {
+ return fmt.Errorf("failed to run migrations: %w", err)
+ }
+ return nil
+}
+
+// Serve starts the HTTP server with the configured routes
+func (s *Server) Serve(addr string) error {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", s.HandleRoot)
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir))))
+ slog.Info("starting server", "addr", addr)
+ return http.ListenAndServe(addr, mux)
+}
+
+func buildHeaderEntries(r *http.Request) []headerEntry {
+ if r == nil {
+ return nil
+ }
+
+ headers := make([]headerEntry, 0, len(r.Header)+1)
+ for name, values := range r.Header {
+ lower := strings.ToLower(name)
+ headers = append(headers, headerEntry{
+ Name: name,
+ Values: values,
+ AddedByExe: strings.HasPrefix(lower, "x-exedev-") || strings.HasPrefix(lower, "x-forwarded-"),
+ })
+ }
+ if r.Host != "" {
+ headers = append(headers, headerEntry{
+ Name: "Host",
+ Values: []string{r.Host},
+ })
+ }
+
+ sort.Slice(headers, func(i, j int) bool {
+ return strings.ToLower(headers[i].Name) < strings.ToLower(headers[j].Name)
+ })
+ return headers
+}
@@ -0,0 +1,117 @@
+package srv
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestServerSetupAndHandlers(t *testing.T) {
+ tempDB := filepath.Join(t.TempDir(), "test_server.sqlite3")
+ t.Cleanup(func() { os.Remove(tempDB) })
+
+ server, err := New(tempDB, "test-hostname")
+ if err != nil {
+ t.Fatalf("failed to create server: %v", err)
+ }
+
+ // Test root endpoint without auth
+ t.Run("root endpoint unauthenticated", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ server.HandleRoot(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200, got %d", w.Code)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "test-hostname") {
+ t.Errorf("expected page to show hostname, got body: %s", body)
+ }
+ if !strings.Contains(body, "Go Template Project") {
+ t.Errorf("expected page to contain headline, got body: %s", body)
+ }
+ if strings.Contains(body, "Signed in as") {
+ t.Errorf("expected page to not be logged in, got body: %s", body)
+ }
+ if !strings.Contains(body, "Not signed in") {
+ t.Errorf("expected page to show 'Not signed in', got body: %s", body)
+ }
+ })
+
+ // Test root endpoint with auth headers
+ t.Run("root endpoint authenticated", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("X-ExeDev-UserID", "user123")
+ req.Header.Set("X-ExeDev-Email", "test@example.com")
+ w := httptest.NewRecorder()
+
+ server.HandleRoot(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status 200, got %d", w.Code)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "Signed in as") {
+ t.Errorf("expected page to show logged in state, got body: %s", body)
+ }
+ if !strings.Contains(body, "test@example.com") {
+ t.Error("expected page to show user email")
+ }
+ })
+
+ // Test view counter functionality
+ t.Run("view counter increments", func(t *testing.T) {
+ // Make first request
+ req1 := httptest.NewRequest(http.MethodGet, "/", nil)
+ req1.Header.Set("X-ExeDev-UserID", "counter-test")
+ req1.RemoteAddr = "192.168.1.100:12345"
+ w1 := httptest.NewRecorder()
+ server.HandleRoot(w1, req1)
+
+ // Should show "1 times" or similar
+ body1 := w1.Body.String()
+ if !strings.Contains(body1, "1</strong> times") {
+ t.Error("expected first visit to show 1 time")
+ }
+
+ // Make second request with same user
+ req2 := httptest.NewRequest(http.MethodGet, "/", nil)
+ req2.Header.Set("X-ExeDev-UserID", "counter-test")
+ req2.RemoteAddr = "192.168.1.100:12345"
+ w2 := httptest.NewRecorder()
+ server.HandleRoot(w2, req2)
+
+ // Should show "2 times" or similar
+ body2 := w2.Body.String()
+ if !strings.Contains(body2, "2</strong> times") {
+ t.Error("expected second visit to show 2 times")
+ }
+ })
+}
+
+func TestUtilityFunctions(t *testing.T) {
+ t.Run("mainDomainFromHost function", func(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"example.exe.cloud:8080", "exe.cloud:8080"},
+ {"example.exe.dev", "exe.dev"},
+ {"example.exe.cloud", "exe.cloud"},
+ }
+
+ for _, test := range tests {
+ result := mainDomainFromHost(test.input)
+ if result != test.expected {
+ t.Errorf("mainDomainFromHost(%q) = %q, expected %q", test.input, result, test.expected)
+ }
+ }
+ })
+}
@@ -0,0 +1,16 @@
+// Handle SSH copy link
+document.querySelectorAll('.ssh-copy').forEach(function(link) {
+ link.addEventListener('click', function(e) {
+ e.preventDefault();
+ var text = this.getAttribute('data-copy');
+ navigator.clipboard.writeText(text).then(function() {
+ var feedback = document.getElementById('copiedFeedback');
+ feedback.classList.add('show');
+ setTimeout(function() {
+ feedback.classList.remove('show');
+ }, 2000);
+ }).catch(function(err) {
+ console.error('Failed to copy:', err);
+ });
+ });
+});
@@ -0,0 +1,501 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ overflow-x: hidden;
+}
+
+body {
+ background-color: #fffff8;
+ background-image: repeating-linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.035) 0px,
+ rgba(0, 0, 0, 0.035) 2px,
+ transparent 2px,
+ transparent 4px
+ );
+ background-size: 4px 4px;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 18px;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 40px 20px;
+ text-align: center;
+}
+
+main {
+ max-width: 900px;
+ width: 100%;
+ overflow-x: hidden;
+ margin: 0 auto;
+}
+
+/* Header: tagline first */
+h1 {
+ font-size: clamp(30px, 6vw, 50px);
+ line-height: 1.2;
+ color: #000;
+ margin-bottom: 30px;
+ font-weight: normal;
+ letter-spacing: -0.01em;
+}
+
+/* Introduction paragraph */
+.intro {
+ max-width: 780px;
+ margin: 0 auto 30px;
+ line-height: 1.7;
+ color: #333;
+ text-align: left;
+ font-size: 16px;
+}
+
+.intro p {
+ margin-bottom: 16px;
+}
+
+.intro p:last-child {
+ margin-bottom: 0;
+}
+
+.intro code {
+ font-family: 'Courier New', Courier, monospace;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 15px;
+}
+
+.next-steps {
+ max-width: 960px;
+ margin: 20px auto 40px;
+ text-align: left;
+}
+
+.next-steps h2 {
+ font-size: 20px;
+ margin-bottom: 16px;
+ font-weight: normal;
+}
+
+.step-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 16px;
+}
+
+.step-card {
+ background: rgba(255, 255, 255, 0.6);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 10px;
+ padding: 18px;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
+}
+
+.step-card h3 {
+ font-size: 16px;
+ margin-bottom: 10px;
+}
+
+.step-card p {
+ margin-bottom: 10px;
+ color: #555;
+ line-height: 1.4;
+}
+
+.step-card ul {
+ list-style: disc;
+ padding-left: 18px;
+ margin: 0;
+ color: #444;
+ font-size: 14px;
+}
+
+.step-card li {
+ margin-bottom: 6px;
+}
+
+.step-card li:last-child {
+ margin-bottom: 0;
+}
+
+.step-card code {
+ font-family: 'Courier New', Courier, monospace;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.step-card a {
+ color: #0066cc;
+ text-decoration: none;
+}
+
+.step-card a:hover {
+ text-decoration: underline;
+}
+
+.hostname {
+ font-size: clamp(28px, 5vw, 36px);
+ line-height: 1.2;
+ color: #000;
+ margin-bottom: 40px;
+ font-weight: normal;
+ letter-spacing: -0.01em;
+ font-family: 'Courier New', Courier, monospace;
+}
+
+/* Decorative rule */
+hr {
+ width: 200px;
+ height: 40px;
+ border: 0;
+ margin: 40px auto;
+ position: relative;
+ opacity: 0.4;
+}
+
+hr::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg,
+ transparent 0%,
+ #333 20%,
+ #333 80%,
+ transparent 100%);
+}
+
+hr::after {
+ content: 'β¦';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 24px;
+ background: #fffff8;
+ padding: 0 10px;
+ color: #333;
+}
+
+/* Action links section */
+.actions {
+ list-style: none;
+ margin: 40px auto;
+ max-width: 800px;
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.actions li {
+ flex: 1 1 0;
+ min-width: 150px;
+ max-width: 200px;
+}
+
+.actions a {
+ color: #0066cc;
+ text-decoration: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 20px 16px;
+ background: rgba(255, 255, 255, 0.6);
+ border: 1px solid rgba(0, 102, 204, 0.3);
+ border-radius: 8px;
+ transition: all 0.2s;
+ height: 100%;
+}
+
+.actions a:hover {
+ background: rgba(0, 102, 204, 0.1);
+ border-color: rgba(0, 102, 204, 0.5);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
+}
+
+.actions a:active {
+ transform: translateY(0);
+}
+
+.action-icon {
+ font-size: 32px;
+ line-height: 1;
+}
+
+.action-label {
+ font-weight: bold;
+ color: #000;
+ font-size: 14px;
+ text-align: center;
+}
+
+.action-detail {
+ font-size: 12px;
+ color: #666;
+ font-family: 'Courier New', Courier, monospace;
+ text-align: center;
+ word-break: break-all;
+}
+
+/* User info section */
+.user-info {
+ margin: 40px auto;
+ padding: 0;
+ max-width: 600px;
+}
+
+.user-status {
+ font-size: 16px;
+ color: #666;
+ margin-bottom: 15px;
+}
+
+.user-status strong {
+ color: #000;
+}
+
+.auth-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.auth-buttons a {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 14px;
+ text-decoration: none;
+ color: #0066cc;
+ padding: 8px 16px;
+ border: 1px solid #0066cc;
+ border-radius: 6px;
+ transition: background 0.2s, color 0.2s;
+}
+
+.auth-buttons a:hover {
+ background: #0066cc;
+ color: #fff;
+}
+
+/* Headers section */
+.headers {
+ margin: 32px auto;
+ padding: 20px;
+ max-width: 900px;
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 12px;
+ text-align: left;
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.05);
+}
+
+.headers-title h2 {
+ font-size: 20px;
+ margin-bottom: 2px;
+ font-weight: normal;
+}
+
+.headers-notes {
+ color: #555;
+ font-size: 15px;
+ line-height: 1.5;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.headers-notes p {
+ margin: 0;
+}
+
+.headers-notes ul {
+ margin: 0 0 4px 20px;
+ padding-left: 18px;
+ list-style: disc;
+}
+
+.headers-notes li {
+ margin-bottom: 4px;
+}
+
+.headers-notes code {
+ font-family: 'Courier New', Courier, monospace;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 1px 5px;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.headers-table {
+ margin-top: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.headers-row {
+ display: grid;
+ grid-template-columns: minmax(140px, 1fr) 2fr;
+ gap: 12px;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.headers-row + .headers-row {
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.headers-head {
+ background: rgba(0, 0, 0, 0.04);
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-size: 12px;
+}
+
+.header-name code,
+.header-value code {
+ font-family: 'Courier New', Courier, monospace;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ word-break: break-all;
+ display: inline-block;
+}
+
+.header-value {
+ color: #222;
+}
+
+.headers-row.exe-header {
+ background: linear-gradient(90deg, rgba(0, 102, 204, 0.12), rgba(0, 102, 204, 0.02));
+ border-left: 3px solid rgba(0, 102, 204, 0.45);
+}
+
+.header-empty {
+ color: #888;
+ font-style: italic;
+}
+
+/* Counter section */
+.counter {
+ margin: 20px auto;
+ font-size: 16px;
+ color: #666;
+}
+
+.counter strong {
+ color: #000;
+}
+
+/* Mobile responsive */
+@media (max-width: 600px) {
+ body {
+ padding: 20px 10px;
+ }
+
+ h1 {
+ margin-bottom: 20px;
+ }
+
+ .intro {
+ font-size: 14px;
+ margin-bottom: 20px;
+ padding: 0 10px;
+ }
+
+ .hostname {
+ margin-bottom: 30px;
+ font-size: clamp(20px, 5vw, 28px);
+ }
+
+ .actions {
+ margin: 30px auto;
+ flex-direction: column;
+ max-width: 100%;
+ padding: 0 10px;
+ }
+
+ .actions li {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .actions a {
+ padding: 16px 12px;
+ }
+
+ .action-icon {
+ font-size: 28px;
+ }
+
+ .action-label {
+ font-size: 13px;
+ }
+
+ .action-detail {
+ font-size: 11px;
+ }
+
+ .user-info {
+ padding: 0;
+ }
+
+ hr {
+ transform: scale(0.8);
+ margin: 30px auto;
+ }
+
+ .headers {
+ padding: 16px;
+ }
+
+ .headers-notes {
+ gap: 6px;
+ }
+
+ .headers-row {
+ grid-template-columns: 1fr;
+ gap: 6px;
+ padding: 10px 12px;
+ }
+}
+
+/* Copy feedback */
+.copied-feedback {
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(100, 180, 255, 0.9);
+ color: #000;
+ padding: 12px 24px;
+ border-radius: 8px;
+ font-size: 14px;
+ opacity: 0;
+ transition: opacity 0.3s;
+ pointer-events: none;
+ z-index: 1000;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.copied-feedback.show {
+ opacity: 1;
+}
@@ -0,0 +1,149 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>{{.Hostname}}</title>
+ <link rel="stylesheet" href="/static/style.css" />
+ </head>
+ <body>
+ <main>
+ <h1>Go Template Project</h1>
+
+ <section class="intro">
+ <p>
+ This is a starter template for building Go web applications on exe.dev. Customize this page and the code to create your own service.
+ </p>
+ <p>
+ This VM has a persistent disk, sudo, HTTPS, and SSH all wired up.
+ </p>
+ </section>
+
+ <section class="next-steps">
+ <h2>What next?</h2>
+ <div class="step-grid">
+ <article class="step-card">
+ <h3>Customize this template</h3>
+ <p>Edit the code to build your application.</p>
+ <ul>
+ <li>Source code is in <code>~</code></li>
+ <li>Edit via SSH or <a href="vscode://vscode-remote/ssh-remote+{{.Hostname}}.exe.xyz/home/exedev?windowId=_blank">open in VS Code</a></li>
+ <li>Chat with our coding agent, <a href="https://{{.Hostname}}.exe.xyz:9999" target="_blank">Shelley</a></li>
+ <li><code>sudo systemctl restart srv</code> to pick up changes</li>
+ </ul>
+ </article>
+ <article class="step-card">
+ <h3>What's included</h3>
+ <p>This template provides the essentials.</p>
+ <ul>
+ <li>HTTP server with routing and templates</li>
+ <li>SQLite database with migrations</li>
+ <li>Authentication via exe.dev headers</li>
+ <li>Systemd service configuration</li>
+ </ul>
+ </article>
+ </div>
+ </section>
+
+ <div class="hostname">{{.Hostname}}</div>
+
+ <ul class="actions">
+ <li>
+ <a href="#" class="ssh-copy" data-copy="ssh {{.Hostname}}.exe.xyz">
+ <span class="action-icon">π</span>
+ <span class="action-label">SSH</span>
+ <span class="action-detail">ssh {{.Hostname}}.exe.xyz</span>
+ </a>
+ </li>
+ <li>
+ <a href="https://{{.Hostname}}.xterm.exe.xyz" target="_blank">
+ <span class="action-icon">π»</span>
+ <span class="action-label">Terminal</span>
+ <span class="action-detail">{{.Hostname}}.xterm.exe.xyz</span>
+ </a>
+ </li>
+ <li>
+ <a href="https://{{.Hostname}}.exe.xyz:9999" target="_blank">
+ <span class="action-icon">π€</span>
+ <span class="action-label">Shelley Agent</span>
+ <span class="action-detail">{{.Hostname}}.exe.xyz:9999</span>
+ </a>
+ </li>
+ <li>
+ <a href="https://exe.dev" target="_blank">
+ <span class="action-icon">π </span>
+ <span class="action-label">exe.dev</span>
+ <span class="action-detail">exe.dev</span>
+ </a>
+ </li>
+ </ul>
+
+ <hr />
+
+ <div class="user-info">
+ <p class="user-status">
+ {{if .UserEmail}}
+ Signed in as <strong>{{.UserEmail}}</strong>
+ {{else}}
+ Not signed in
+ {{end}}
+ </p>
+ <div class="auth-buttons">
+ {{if .UserEmail}}
+ <form method="POST" action="{{.LogoutURL}}" style="display:inline">
+ <button type="submit">logout</button>
+ </form>
+ {{else}}
+ <a href="{{.LoginURL}}">login</a>
+ {{end}}
+ </div>
+ </div>
+
+ {{if .Headers}}
+ <section class="headers">
+ <div class="headers-title">
+ <h2>HTTP headers from exe.dev</h2>
+ <div class="headers-notes">
+ <p>exe.dev adds extra headers to HTTP requests so that:</p>
+ <ul>
+ <li>you don't have to build auth</li>
+ <li>you know where the request came from</li>
+ </ul>
+ <p>These are all the HTTP headers we received from exe.dev for this request.</p>
+ <p>The <code>X-ExeDev-*</code> and <code>X-Forwarded-*</code> headers are added by exe.dev.</p>
+ </div>
+ </div>
+ <div class="headers-table">
+ <div class="headers-row headers-head">
+ <div>Header</div>
+ <div>Value</div>
+ </div>
+ {{range .Headers}}
+ <div class="headers-row{{if .AddedByExe}} exe-header{{end}}">
+ <div class="header-name"><code>{{.Name}}</code></div>
+ <div class="header-value">
+ {{if .Values}}
+ {{range $i, $value := .Values}}
+ {{if $i}}<br />{{end}}
+ <code>{{$value}}</code>
+ {{end}}
+ {{else}}
+ <span class="header-empty">β</span>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </section>
+ {{end}}
+
+ {{if .VisitCount}}
+ <p class="counter">You've viewed this page <strong>{{.VisitCount}}</strong> times.</p>
+ {{end}}
+ </main>
+
+ <div class="copied-feedback" id="copiedFeedback">Copied to clipboard!</div>
+
+ <script src="/static/script.js"></script>
+ </body>
+</html>
@@ -0,0 +1,111 @@
+// Package templates provides embedded project templates for shelley.
+package templates
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "embed"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+//go:embed *.tar.gz
+var FS embed.FS
+
+// List returns the names of all available templates.
+func List() ([]string, error) {
+ entries, err := FS.ReadDir(".")
+ if err != nil {
+ return nil, fmt.Errorf("read templates dir: %w", err)
+ }
+ var names []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if strings.HasSuffix(name, ".tar.gz") {
+ names = append(names, strings.TrimSuffix(name, ".tar.gz"))
+ }
+ }
+ return names, nil
+}
+
+// Unpack extracts the named template to the given directory.
+// The directory must exist and should be empty.
+func Unpack(templateName, destDir string) error {
+ tarPath := templateName + ".tar.gz"
+ f, err := FS.Open(tarPath)
+ if err != nil {
+ return fmt.Errorf("open template %q: %w", templateName, err)
+ }
+ defer f.Close()
+
+ gz, err := gzip.NewReader(f)
+ if err != nil {
+ return fmt.Errorf("gzip reader: %w", err)
+ }
+ defer gz.Close()
+
+ tr := tar.NewReader(gz)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("read tar: %w", err)
+ }
+
+ // Sanitize path to prevent directory traversal
+ cleanName := filepath.Clean(hdr.Name)
+ if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
+ return fmt.Errorf("invalid path in archive: %s", hdr.Name)
+ }
+
+ target := filepath.Join(destDir, cleanName)
+
+ switch hdr.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", target, err)
+ }
+ case tar.TypeReg:
+ // Ensure parent directory exists
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return fmt.Errorf("mkdir for %s: %w", target, err)
+ }
+ // Create the file
+ mode := os.FileMode(hdr.Mode)
+ if mode == 0 {
+ mode = 0o644
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+ if err != nil {
+ return fmt.Errorf("create %s: %w", target, err)
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return fmt.Errorf("write %s: %w", target, err)
+ }
+ out.Close()
+ case tar.TypeSymlink:
+ // Validate symlink target
+ linkTarget := hdr.Linkname
+ if filepath.IsAbs(linkTarget) {
+ return fmt.Errorf("absolute symlink not allowed: %s -> %s", hdr.Name, linkTarget)
+ }
+ // Ensure parent directory exists
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return fmt.Errorf("mkdir for symlink %s: %w", target, err)
+ }
+ if err := os.Symlink(linkTarget, target); err != nil {
+ return fmt.Errorf("symlink %s: %w", target, err)
+ }
+ }
+ }
+ return nil
+}
@@ -0,0 +1,309 @@
+package test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/server"
+)
+
+func TestWithAnthropicAPI(t *testing.T) {
+ // Skip if no API key
+ apiKey := os.Getenv("ANTHROPIC_API_KEY")
+ if apiKey == "" {
+ t.Skip("ANTHROPIC_API_KEY not set, skipping Anthropic API test")
+ }
+
+ // Create temporary database
+ tempDB := t.TempDir() + "/anthropic_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create LLM service manager
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: slog.LevelInfo, // Less verbose for real API test
+ }))
+ llmConfig := &server.LLMConfig{
+ AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
+ OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
+ GeminiAPIKey: os.Getenv("GEMINI_API_KEY"),
+ FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
+ Logger: logger,
+ }
+ llmManager := server.NewLLMServiceManager(llmConfig, nil)
+
+ // Set up tools config
+ toolSetConfig := claudetool.ToolSetConfig{
+ WorkingDir: t.TempDir(),
+ LLMProvider: llmManager,
+ EnableBrowser: false,
+ }
+
+ // Create server
+ svr := server.NewServer(database, llmManager, toolSetConfig, logger, false, "", "", "", nil)
+
+ // Set up HTTP server
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ testServer := httptest.NewServer(mux)
+ defer testServer.Close()
+
+ t.Run("SimpleConversationWithClaude", func(t *testing.T) {
+ // Create a conversation
+ // Using database directly instead of service
+ slug := "claude-test"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Send a simple message
+ chatReq := map[string]interface{}{
+ "message": "Hello! Please introduce yourself briefly and tell me what you can help me with. Keep your response under 50 words.",
+ "model": "claude-haiku-4.5",
+ }
+ reqBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+ "application/json",
+ bytes.NewReader(reqBody),
+ )
+ if err != nil {
+ t.Fatalf("Failed to send chat message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+ }
+
+ // Wait for processing (Claude API can be slow)
+ time.Sleep(5 * time.Second)
+
+ // Check messages
+ msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to get conversation: %v", err)
+ }
+ defer msgResp.Body.Close()
+
+ if msgResp.StatusCode != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", msgResp.StatusCode)
+ }
+
+ var payload server.StreamResponse
+ if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+ t.Fatalf("Failed to decode messages: %v", err)
+ }
+
+ // Should have system message, user message and assistant response
+ if len(payload.Messages) < 3 {
+ msgTypes := make([]string, len(payload.Messages))
+ for i, msg := range payload.Messages {
+ msgTypes[i] = msg.Type
+ }
+ t.Fatalf("Expected at least 3 messages (system + user + assistant), got %d: %v", len(payload.Messages), msgTypes)
+ }
+
+ // Check first message is system prompt
+ if payload.Messages[0].Type != "system" {
+ t.Fatalf("Expected first message to be system, got %s", payload.Messages[0].Type)
+ }
+
+ // Check user message is second
+ if payload.Messages[1].Type != "user" {
+ t.Fatalf("Expected second message to be user, got %s", payload.Messages[1].Type)
+ }
+
+ // Check assistant response
+ assistantFound := false
+ for _, msg := range payload.Messages {
+ if msg.Type == "agent" {
+ assistantFound = true
+ if msg.LlmData == nil {
+ t.Fatal("Assistant message has no LLM data")
+ }
+
+ // Parse and check the response content
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ t.Fatalf("Failed to unmarshal LLM data: %v", err)
+ }
+
+ if len(llmMsg.Content) == 0 {
+ t.Fatal("Assistant response has no content")
+ }
+
+ responseText := llmMsg.Content[0].Text
+ if responseText == "" {
+ t.Fatal("Assistant response text is empty")
+ }
+
+ // Claude should mention being Claude or an AI assistant
+ lowerResponse := strings.ToLower(responseText)
+ if !strings.Contains(lowerResponse, "claude") && !strings.Contains(lowerResponse, "assistant") {
+ t.Logf("Response: %s", responseText)
+ // This is not a hard failure - Claude might respond differently
+ }
+
+ t.Logf("Claude responded: %s", responseText)
+ break
+ }
+ }
+
+ if !assistantFound {
+ t.Fatal("No assistant response found")
+ }
+ })
+
+ t.Run("ConversationWithToolUse", func(t *testing.T) {
+ // Create a conversation
+ // Using database directly instead of service
+ slug := "tool-test"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Ask Claude to think about something
+ chatReq := map[string]interface{}{
+ "message": "Please use the think tool to plan how you would help someone learn to code. Keep it brief.",
+ "model": "claude-haiku-4.5",
+ }
+ reqBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+ "application/json",
+ bytes.NewReader(reqBody),
+ )
+ if err != nil {
+ t.Fatalf("Failed to send chat message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+ }
+
+ // Wait for processing (tool use might take longer)
+ time.Sleep(8 * time.Second)
+
+ // Check messages
+ msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to get conversation: %v", err)
+ }
+ defer msgResp.Body.Close()
+
+ var payload server.StreamResponse
+ if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+ t.Fatalf("Failed to decode messages: %v", err)
+ }
+
+ // Should have multiple messages due to tool use
+ if len(payload.Messages) < 3 {
+ t.Logf("Got %d messages, expected at least 3 for tool use interaction", len(payload.Messages))
+ // This might not always be the case depending on Claude's response
+ }
+
+ // Log all messages for debugging
+ for i, msg := range payload.Messages {
+ t.Logf("Message %d: Type=%s", i, msg.Type)
+ if msg.LlmData != nil {
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+ if len(llmMsg.Content) > 0 && llmMsg.Content[0].Text != "" {
+ t.Logf(" Content: %s", llmMsg.Content[0].Text[:min(100, len(llmMsg.Content[0].Text))])
+ }
+ }
+ }
+ }
+ })
+
+ t.Run("StreamingEndpoint", func(t *testing.T) {
+ // Create a conversation with a message
+ // Using database directly instead of service
+ // Using database directly instead of service
+ slug := "stream-test"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Add a test message
+ testMsg := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Hello streaming test"},
+ },
+ }
+ _, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeUser,
+ LLMData: testMsg,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create message: %v", err)
+ }
+
+ // Test stream endpoint
+ resp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+ if err != nil {
+ t.Fatalf("Failed to get stream: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Check headers
+ if resp.Header.Get("Content-Type") != "text/event-stream" {
+ t.Fatal("Expected text/event-stream content type")
+ }
+
+ // Read first chunk (should contain current messages)
+ buf := make([]byte, 2048)
+ n, err := resp.Body.Read(buf)
+ if err != nil && err != io.EOF {
+ t.Fatalf("Failed to read stream: %v", err)
+ }
+
+ data := string(buf[:n])
+ if !strings.Contains(data, "data: ") {
+ t.Fatal("Expected SSE data format")
+ }
+
+ t.Logf("Received stream data: %s", data[:min(200, len(data))])
+ })
+}
+
+// Helper function for min
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
@@ -0,0 +1,1011 @@
+package test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/claudetool/browse"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+ "shelley.exe.dev/server"
+ "shelley.exe.dev/slug"
+)
+
+func TestServerEndToEnd(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create logger first
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ }))
+
+ // Create LLM service manager with predictable service
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ predictableService := loop.NewPredictableService()
+ // For testing, we'll override the manager's service selection
+ _ = predictableService // will need to mock this properly
+
+ // Set up tools
+ // Set up tools config
+ toolSetConfig := claudetool.ToolSetConfig{
+ WorkingDir: t.TempDir(),
+ EnableBrowser: false,
+ }
+
+ // Create server
+ svr := server.NewServer(database, llmManager, toolSetConfig, logger, false, "", "", "", nil)
+
+ // Set up HTTP server
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ testServer := httptest.NewServer(mux)
+ defer testServer.Close()
+
+ t.Run("CreateAndListConversations", func(t *testing.T) {
+ // Create a conversation
+ // Using database directly instead of service
+ slug := "test-conversation"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // List conversations
+ resp, err := http.Get(testServer.URL + "/api/conversations")
+ if err != nil {
+ t.Fatalf("Failed to get conversations: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ var conversations []generated.Conversation
+ if err := json.NewDecoder(resp.Body).Decode(&conversations); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ if len(conversations) != 1 {
+ t.Fatalf("Expected 1 conversation, got %d", len(conversations))
+ }
+
+ if conversations[0].ConversationID != conv.ConversationID {
+ t.Fatalf("Conversation ID mismatch")
+ }
+ })
+
+ t.Run("ChatEndToEnd", func(t *testing.T) {
+ // Create a conversation
+ // Using database directly instead of service
+ slug := "chat-test"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Send a chat message using predictable model
+ chatReq := map[string]interface{}{"message": "Hello, can you help me?", "model": "predictable"}
+ reqBody, _ := json.Marshal(chatReq)
+
+ resp, err := http.Post(
+ testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+ "application/json",
+ bytes.NewReader(reqBody),
+ )
+ if err != nil {
+ t.Fatalf("Failed to send chat message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+ }
+
+ // Wait a bit for processing
+ time.Sleep(500 * time.Millisecond)
+
+ // Check messages
+ msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to get conversation: %v", err)
+ }
+ defer msgResp.Body.Close()
+
+ if msgResp.StatusCode != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", msgResp.StatusCode)
+ }
+
+ var payload server.StreamResponse
+ if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+ t.Fatalf("Failed to decode messages: %v", err)
+ }
+
+ // Should have at least system and user messages
+ if len(payload.Messages) < 2 {
+ t.Fatalf("Expected at least 2 messages (system + user), got %d", len(payload.Messages))
+ }
+
+ // First message should be system prompt
+ if payload.Messages[0].Type != "system" {
+ t.Fatalf("Expected first message to be system, got %s", payload.Messages[0].Type)
+ }
+
+ // Second message should be from user
+ if payload.Messages[1].Type != "user" {
+ t.Fatalf("Expected second message to be user, got %s", payload.Messages[1].Type)
+ }
+ })
+
+ t.Run("StreamEndpoint", func(t *testing.T) {
+ // Create a conversation with some messages
+ // Using database directly instead of service
+ // Using database directly instead of service
+ slug := "stream-test"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Add a test message
+ testMsg := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: "Test message"},
+ },
+ }
+ _, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeUser,
+ LLMData: testMsg,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create message: %v", err)
+ }
+
+ // Test stream endpoint
+ resp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+ if err != nil {
+ t.Fatalf("Failed to get stream: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Check headers
+ if resp.Header.Get("Content-Type") != "text/event-stream" {
+ t.Fatal("Expected text/event-stream content type")
+ }
+
+ // Read first event (should be current messages)
+ buf := make([]byte, 1024)
+ n, err := resp.Body.Read(buf)
+ if err != nil && err != io.EOF {
+ t.Fatalf("Failed to read stream: %v", err)
+ }
+
+ data := string(buf[:n])
+ if !strings.Contains(data, "data: ") {
+ t.Fatal("Expected SSE data format")
+ }
+ })
+
+ // Test that slug updates are reflected in the stream
+ t.Run("SlugUpdateStream", func(t *testing.T) {
+ // Create a context that won't be canceled unexpectedly
+ ctx := context.Background()
+
+ // Create a conversation without a slug
+ conv, err := database.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Verify initially no slug
+ if conv.Slug != nil {
+ t.Fatalf("Expected no initial slug, got: %v", *conv.Slug)
+ }
+
+ // Send a message which should trigger slug generation
+ chatRequest := server.ChatRequest{
+ Message: "Write a Python script to calculate fibonacci numbers",
+ Model: "predictable",
+ }
+
+ chatBody, _ := json.Marshal(chatRequest)
+ chatResp, err := http.Post(
+ testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+ "application/json",
+ strings.NewReader(string(chatBody)),
+ )
+ if err != nil {
+ t.Fatalf("Failed to send chat message: %v", err)
+ }
+ defer chatResp.Body.Close()
+
+ // Check response status before continuing
+ if chatResp.StatusCode != http.StatusAccepted {
+ t.Fatalf("Expected status 202, got %d", chatResp.StatusCode)
+ }
+
+ // Wait longer for slug generation (it happens asynchronously)
+ // Poll every 100ms instead of 500ms for faster feedback
+ for i := 0; i < 100; i++ {
+ time.Sleep(100 * time.Millisecond)
+
+ // Check if slug was generated
+ updatedConv, err := database.GetConversationByID(ctx, conv.ConversationID)
+ if err != nil {
+ // Don't fail immediately on error - the conversation might be temporarily locked
+ // Only fail if we've exhausted all retries
+ if i == 99 {
+ t.Fatalf("Failed to get updated conversation after all retries: %v", err)
+ }
+ continue
+ }
+
+ if updatedConv.Slug != nil {
+ t.Logf("Slug generated successfully: %s", *updatedConv.Slug)
+ return
+ }
+ }
+
+ t.Fatal("Slug was not generated within timeout period")
+ })
+
+ t.Run("ErrorHandling", func(t *testing.T) {
+ // Test non-existent conversation
+ resp, err := http.Get(testServer.URL + "/api/conversation/nonexistent")
+ if err != nil {
+ t.Fatalf("Failed to make request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Should handle gracefully (might be empty list or error depending on implementation)
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("Unexpected status code: %d", resp.StatusCode)
+ }
+
+ // Test invalid chat request
+ invalidReq := map[string]string{"not_message": "test"}
+ reqBody, _ := json.Marshal(invalidReq)
+ chatResp, err := http.Post(
+ testServer.URL+"/api/conversation/test/chat",
+ "application/json",
+ bytes.NewReader(reqBody),
+ )
+ if err != nil {
+ t.Fatalf("Failed to send invalid chat: %v", err)
+ }
+ defer chatResp.Body.Close()
+
+ if chatResp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("Expected status 400 for invalid request, got %d", chatResp.StatusCode)
+ }
+ })
+}
+
+func TestPredictableServiceWithTools(t *testing.T) {
+ // Test that the predictable service correctly handles tool calls
+ service := loop.NewPredictableService()
+
+ // First call should return greeting
+ resp1, err := service.Do(context.Background(), &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("First call failed: %v", err)
+ }
+
+ if !strings.Contains(resp1.Content[0].Text, "Shelley") {
+ t.Fatal("Expected greeting to mention Shelley")
+ }
+
+ // Second call should return tool use
+ resp2, err := service.Do(context.Background(), &llm.Request{
+ Messages: []llm.Message{
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Create an example"}}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("Second call failed: %v", err)
+ }
+
+ if resp2.StopReason != llm.StopReasonToolUse {
+ t.Fatal("Expected tool use stop reason")
+ }
+
+ if len(resp2.Content) < 2 {
+ t.Fatal("Expected both text and tool use content")
+ }
+
+ // Find tool use content
+ var toolUse *llm.Content
+ for i := range resp2.Content {
+ if resp2.Content[i].Type == llm.ContentTypeToolUse {
+ toolUse = &resp2.Content[i]
+ break
+ }
+ }
+
+ if toolUse == nil {
+ t.Fatal("Expected tool use content")
+ }
+
+ if toolUse.ToolName != "think" {
+ t.Fatalf("Expected think tool, got %s", toolUse.ToolName)
+ }
+}
+
+func TestConversationCleanup(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/cleanup_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create server with predictable service
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+ // Create a conversation
+ // Using database directly instead of service
+ conv, err := database.CreateConversation(context.Background(), nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Test cleanup indirectly by calling cleanup
+ svr.Cleanup()
+
+ // Test passes if no panic occurs
+ t.Log("Cleanup completed successfully for conversation:", conv.ConversationID)
+}
+
+func TestSlugGeneration(t *testing.T) {
+ // This test verifies that the slug generation logic is properly integrated
+ // but uses the direct API to avoid timing issues with background goroutines
+
+ // Create temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create server
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ _ = server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+ // Test slug generation directly to avoid timing issues
+ // ctx := context.Background()
+ // testMessage := "help me create a Python web server"
+
+ // TODO: Fix slug generation test - method moved to slug package
+ // Generate slug directly
+ // slugResult, err := svr.GenerateSlugForConversation(ctx, testMessage)
+ // if err != nil {
+ // t.Fatalf("Slug generation failed: %v", err)
+ // }
+ // if slugResult == "" {
+ // t.Error("Generated slug is empty")
+ // } else {
+ // t.Logf("Generated slug: %s", slugResult)
+ // }
+
+ // TODO: Fix slug tests
+ // Test that the slug is properly sanitized
+ // if !strings.Contains(slugResult, "python") || !strings.Contains(slugResult, "web") {
+ // t.Logf("Note: Generated slug '%s' may not contain expected keywords, but this is acceptable for AI-generated content", slugResult)
+ // }
+
+ // // Verify slug uniqueness handling
+ // conv, err := database.CreateConversation(ctx, &slugResult, true)
+ // if err != nil {
+ // t.Fatalf("Failed to create conversation with slug: %v", err)
+ // }
+
+ // TODO: Fix slug generation test
+ // Try to generate the same slug again - should get a unique variant
+ // slugResult2, err := svr.GenerateSlugForConversation(ctx, testMessage)
+ // if err != nil {
+ // t.Fatalf("Second slug generation failed: %v", err)
+ // }
+
+ // // The second slug should be different (with -1, -2, etc.)
+ // if slugResult == slugResult2 {
+ // t.Errorf("Expected different slugs for uniqueness, but got same: %s", slugResult)
+ // } else {
+ // t.Logf("Unique slug generated: %s", slugResult2)
+ // }
+
+ // _ = conv // avoid unused variable warning
+}
+
+func TestSanitizeSlug(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"basic text", "Hello World", "hello-world"},
+ {"with numbers", "Python3 Tutorial", "python3-tutorial"},
+ {"with special chars", "C++ Programming!", "c-programming"},
+ {"multiple spaces", "Very Long Title", "very-long-title"},
+ {"underscores", "test_function_name", "test-function-name"},
+ {"mixed case", "CamelCaseExample", "camelcaseexample"},
+ {"with hyphens", "pre-existing-hyphens", "pre-existing-hyphens"},
+ {"leading/trailing spaces", " trimmed ", "trimmed"},
+ {"leading/trailing hyphens", "-start-end-", "start-end"},
+ {"multiple consecutive hyphens", "test---slug", "test-slug"},
+ {"empty after sanitization", "!@#$%^&*()", ""},
+ {"very long", "this-is-a-very-long-slug-that-should-be-truncated-because-it-exceeds-the-maximum-length", "this-is-a-very-long-slug-that-should-be-truncated-because-it"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := slug.Sanitize(tt.input)
+ if result != tt.expected {
+ t.Errorf("SanitizeSlug(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSlugGenerationWithPredictableService(t *testing.T) {
+ // Create server with predictable service only
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+ // Create a temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ _ = server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+ // Test slug generation directly
+ // ctx := context.Background()
+ // testMessage := "help me write a python function"
+
+ // TODO: Fix slug generation test
+ // This should work with the predictable service falling back
+ // slugResult, err := svr.GenerateSlugForConversation(ctx, testMessage)
+ // if err != nil {
+ // t.Fatalf("Slug generation failed: %v", err)
+ // }
+ // if slugResult == "" {
+ // t.Error("Generated slug is empty")
+ // }
+ // t.Logf("Generated slug: %s", slugResult)
+
+ // TODO: Fix slug sanitization test
+ // Test slug sanitization which should always work
+ // slug := slug.Sanitize(testMessage)
+ // if slug != "help-me-write-a-python-function" {
+ // t.Errorf("Expected 'help-me-write-a-python-function', got '%s'", slug)
+ // }
+}
+
+func TestSlugEndToEnd(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create a conversation with a specific slug
+ ctx := context.Background()
+ testSlug := "test-conversation-slug"
+ conv, err := database.CreateConversation(ctx, &testSlug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Test retrieving by slug
+ retrievedBySlug, err := database.GetConversationBySlug(ctx, testSlug)
+ if err != nil {
+ t.Fatalf("Failed to retrieve conversation by slug: %v", err)
+ }
+
+ if retrievedBySlug.ConversationID != conv.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, retrievedBySlug.ConversationID)
+ }
+
+ if retrievedBySlug.Slug == nil || *retrievedBySlug.Slug != testSlug {
+ t.Errorf("Expected slug %s, got %v", testSlug, retrievedBySlug.Slug)
+ }
+
+ // Test retrieving by ID still works
+ retrievedByID, err := database.GetConversationByID(ctx, conv.ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to retrieve conversation by ID: %v", err)
+ }
+
+ if retrievedByID.ConversationID != conv.ConversationID {
+ t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, retrievedByID.ConversationID)
+ }
+
+ t.Logf("Successfully tested slug-based conversation retrieval: %s -> %s", testSlug, conv.ConversationID)
+}
+
+// Test that slug updates are reflected in the stream
+
+// Test that SSE only sends incremental message updates
+func TestSSEIncrementalUpdates(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create logger and LLM manager
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+ // Create server
+ serviceInstance := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+ mux := http.NewServeMux()
+ serviceInstance.RegisterRoutes(mux)
+ testServer := httptest.NewServer(mux)
+ defer testServer.Close()
+
+ // Create a conversation with initial message
+ slug := "test-sse"
+ conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Add initial message
+ _, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeUser,
+ LLMData: &llm.Message{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+ UserData: map[string]string{"content": "Hello"},
+ UsageData: llm.Usage{},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create initial message: %v", err)
+ }
+
+ // Create first SSE client
+ client1, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+ if err != nil {
+ t.Fatalf("Failed to connect client1: %v", err)
+ }
+ defer client1.Body.Close()
+
+ // Read initial response from client1 (should contain the first message)
+ buf1 := make([]byte, 2048)
+ n1, err := client1.Body.Read(buf1)
+ if err != nil && err != io.EOF {
+ t.Fatalf("Failed to read from client1: %v", err)
+ }
+
+ response1 := string(buf1[:n1])
+ t.Logf("Client1 initial response: %s", response1)
+
+ // Verify client1 received the initial message
+ if !strings.Contains(response1, "Hello") {
+ t.Fatal("Client1 should have received initial message")
+ }
+
+ // Add a second message
+ _, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeAgent,
+ LLMData: &llm.Message{Role: llm.MessageRoleAssistant, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hi there!"}}},
+ UserData: map[string]string{"content": "Hi there!"},
+ UsageData: llm.Usage{},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create second message: %v", err)
+ }
+
+ // Create second SSE client after the new message is added
+ client2, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+ if err != nil {
+ t.Fatalf("Failed to connect client2: %v", err)
+ }
+ defer client2.Body.Close()
+
+ // Read response from client2 (should contain both messages since it's a new client)
+ buf2 := make([]byte, 2048)
+ n2, err := client2.Body.Read(buf2)
+ if err != nil && err != io.EOF {
+ t.Fatalf("Failed to read from client2: %v", err)
+ }
+
+ response2 := string(buf2[:n2])
+ t.Logf("Client2 initial response: %s", response2)
+
+ // Verify client2 received both messages (new client gets full state)
+ if !strings.Contains(response2, "Hello") {
+ t.Fatal("Client2 should have received first message")
+ }
+ if !strings.Contains(response2, "Hi there!") {
+ t.Fatal("Client2 should have received second message")
+ }
+
+ t.Log("SSE incremental updates test completed successfully")
+}
+
+// TestSystemPromptSentToLLM verifies that the system prompt is included in LLM requests
+func TestSystemPromptSentToLLM(t *testing.T) {
+ ctx := context.Background()
+
+ // Create database and server with predictable service
+ // Note: :memory: is not supported by our DB wrapper since it requires multiple connections.
+ // Use a temp file-backed database for tests.
+ tempDB := t.TempDir() + "/system_prompt_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create database: %v", err)
+ }
+ defer database.Close()
+
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+
+ // Create a predictable service we can inspect
+ predictableService := loop.NewPredictableService()
+
+ // Create a custom LLM manager that returns our inspectable predictable service
+ customLLMManager := &inspectableLLMManager{
+ predictableService: predictableService,
+ logger: logger,
+ }
+
+ tools := claudetool.ToolSetConfig{}
+ svr := server.NewServer(database, customLLMManager, tools, logger, false, "", "", "", nil)
+
+ // Start server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ mux.ServeHTTP(w, r)
+ }))
+ defer ts.Close()
+
+ // Test 1: Create new conversation and send first message
+ t.Run("FirstMessage", func(t *testing.T) {
+ predictableService.ClearRequests()
+
+ // Send first message using /api/conversations/new
+ chatReq := map[string]interface{}{
+ "message": "Hello",
+ "model": "predictable",
+ }
+ body, _ := json.Marshal(chatReq)
+ resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+ if err != nil {
+ t.Fatalf("Failed to send message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+ }
+
+ // Poll for async processing completion
+ // We need to wait for a request WITH a system prompt, not just any request
+ var lastReq *llm.Request
+ for i := 0; i < 50; i++ {
+ lastReq = predictableService.GetLastRequest()
+ if lastReq != nil && len(lastReq.System) > 0 {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if lastReq == nil {
+ t.Fatal("No request was sent to the LLM service after 5 seconds")
+ }
+
+ if len(lastReq.System) == 0 {
+ t.Fatal("System prompt was not included in the LLM request")
+ }
+
+ // Verify system prompt contains expected content
+ systemText := ""
+ for _, sys := range lastReq.System {
+ systemText += sys.Text
+ }
+ if !strings.Contains(systemText, "Shelley") {
+ t.Errorf("System prompt doesn't contain 'Shelley': %s", systemText)
+ }
+ if !strings.Contains(systemText, "coding agent") {
+ t.Errorf("System prompt doesn't contain 'coding agent': %s", systemText)
+ }
+
+ t.Logf("System prompt successfully sent (length: %d chars)", len(systemText))
+ })
+
+ // Test 2: Send second message in existing conversation
+ t.Run("SubsequentMessage", func(t *testing.T) {
+ predictableService.ClearRequests()
+
+ // Create conversation first
+ chatReq := map[string]interface{}{
+ "message": "Hello",
+ "model": "predictable",
+ }
+ body, _ := json.Marshal(chatReq)
+ resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+ if err != nil {
+ t.Fatalf("Failed to send first message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var createResp struct {
+ ConversationID string `json:"conversation_id"`
+ }
+ if resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Failed to decode response (status %d): %v, body: %s", resp.StatusCode, err, body)
+ }
+
+ conversationID := createResp.ConversationID
+
+ // Wait for first message to be processed
+ var firstReq *llm.Request
+ for i := 0; i < 50; i++ {
+ firstReq = predictableService.GetLastRequest()
+ if firstReq != nil {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if firstReq == nil {
+ t.Fatal("First request was not sent to the LLM service after 5 seconds")
+ }
+
+ // Clear requests and send second message
+ predictableService.ClearRequests()
+
+ chatReq2 := map[string]interface{}{
+ "message": "what is the date",
+ "model": "predictable",
+ }
+ body2, _ := json.Marshal(chatReq2)
+ resp2, err := http.Post(ts.URL+"/api/conversation/"+conversationID+"/chat", "application/json", bytes.NewBuffer(body2))
+ if err != nil {
+ t.Fatalf("Failed to send second message: %v", err)
+ }
+ defer resp2.Body.Close()
+
+ if resp2.StatusCode != http.StatusAccepted {
+ body, _ := io.ReadAll(resp2.Body)
+ t.Fatalf("Expected status 202, got %d: %s", resp2.StatusCode, body)
+ }
+
+ // Poll for second message to be processed
+ // We need to wait for a request WITH a system prompt, not just any request
+ var lastReq *llm.Request
+ for i := 0; i < 50; i++ {
+ lastReq = predictableService.GetLastRequest()
+ if lastReq != nil && len(lastReq.System) > 0 {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if lastReq == nil {
+ t.Fatal("No request was sent to the LLM service after 5 seconds")
+ }
+
+ if len(lastReq.System) == 0 {
+ t.Fatal("System prompt was not included in subsequent LLM request")
+ }
+
+ // Verify system prompt contains expected content
+ systemText := ""
+ for _, sys := range lastReq.System {
+ systemText += sys.Text
+ }
+ if !strings.Contains(systemText, "Shelley") {
+ t.Errorf("System prompt doesn't contain 'Shelley' in subsequent request: %s", systemText)
+ }
+
+ t.Logf("System prompt successfully sent in subsequent message (length: %d chars)", len(systemText))
+ })
+}
+
+// inspectableLLMManager is a test helper that always returns the same predictable service
+type inspectableLLMManager struct {
+ predictableService *loop.PredictableService
+ logger *slog.Logger
+}
+
+func (m *inspectableLLMManager) GetService(modelID string) (llm.Service, error) {
+ if modelID != "predictable" {
+ return nil, fmt.Errorf("unsupported model: %s", modelID)
+ }
+ return m.predictableService, nil
+}
+
+func (m *inspectableLLMManager) GetAvailableModels() []string {
+ return []string{"predictable"}
+}
+
+func (m *inspectableLLMManager) HasModel(modelID string) bool {
+ return modelID == "predictable"
+}
+
+func TestVersionEndpoint(t *testing.T) {
+ // Create temp DB-backed server
+ ctx := context.Background()
+ tempDB := t.TempDir() + "/version_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create database: %v", err)
+ }
+ defer database.Close()
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
+
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ // Request /version endpoint
+ resp, err := http.Get(ts.URL + "/version")
+ if err != nil {
+ t.Fatalf("GET /version failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
+ }
+
+ if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("expected application/json, got %q", ct)
+ }
+
+ // Parse the response
+ var versionInfo struct {
+ Commit string `json:"commit"`
+ CommitTime string `json:"commit_time"`
+ Modified bool `json:"modified"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
+ t.Fatalf("Failed to decode version info: %v", err)
+ }
+
+ t.Logf("Version info: %+v", versionInfo)
+}
+
+func TestScreenshotRouteServesImage(t *testing.T) {
+ // Create temp DB-backed server
+ ctx := context.Background()
+ tempDB := t.TempDir() + "/route_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create database: %v", err)
+ }
+ defer database.Close()
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
+
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ // Create a fake screenshot file in the expected location
+ id := "testshot"
+ path := browse.GetScreenshotPath(id)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("Failed to create screenshot dir: %v", err)
+ }
+ pngData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic, minimal content
+ if err := os.WriteFile(path, pngData, 0o644); err != nil {
+ t.Fatalf("Failed to write screenshot: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Remove(path) })
+
+ // Request the screenshot
+ resp, err := http.Get(ts.URL + "/api/read?path=" + url.QueryEscape(path))
+ if err != nil {
+ t.Fatalf("GET screenshot failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
+ }
+ if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
+ t.Fatalf("expected image/png, got %q", ct)
+ }
+ // Cache-Control should be set
+ if cc := resp.Header.Get("Cache-Control"); cc == "" {
+ t.Fatalf("expected Cache-Control header to be set")
+ }
+}
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+echo "==> Running Shelley CI Tests"
+echo ""
+
+echo "==> Installing UI dependencies..."
+cd ui
+npm ci
+cd ..
+
+echo ""
+echo "==> Running TypeScript type check..."
+cd ui
+npm run type-check
+cd ..
+
+echo ""
+echo "==> Running ESLint..."
+cd ui
+npm run lint
+cd ..
+
+echo ""
+echo "==> Building UI..."
+cd ui
+npm run build
+cd ..
+
+echo ""
+echo "==> Running Go tests..."
+go test -v ./...
+
+echo ""
+echo "==> Running Playwright E2E tests..."
+cd ui
+npx playwright install --with-deps chromium
+npx playwright test
+cd ..
+
+echo ""
+echo "==> All Shelley tests passed! β"
@@ -0,0 +1,72 @@
+#!/bin/bash
+
+# Manual test script for Shelley server
+# Usage: ./test_manual.sh [port]
+
+set -e
+
+PORT=${1:-8080}
+BASE_URL="http://localhost:$PORT"
+
+echo "=== Shelley Manual Test Script ==="
+echo "Testing server at $BASE_URL"
+echo
+
+# Function to make HTTP requests with better error handling
+make_request() {
+ local method=$1
+ local url=$2
+ local data=$3
+
+ echo "Making $method request to $url"
+ if [ -n "$data" ]; then
+ echo "Request body: $data"
+ fi
+
+ if [ -n "$data" ]; then
+ curl -s -X "$method" -H "Content-Type: application/json" -d "$data" "$url" || echo "Request failed"
+ else
+ curl -s -X "$method" "$url" || echo "Request failed"
+ fi
+
+ echo
+ echo "---"
+ echo
+}
+
+echo "1. Testing server health by listing conversations..."
+make_request "GET" "$BASE_URL/conversations"
+
+echo "2. Creating a test conversation..."
+echo " Note: This test assumes a conversation exists. If not, create one via the database or modify the server to auto-create."
+echo
+
+echo "3. Testing with a sample conversation ID (replace with real ID)..."
+echo " For a real test, first start the server, create a conversation via the database,"
+echo " then use that conversation ID in the following requests."
+echo
+echo " Example conversation creation (using sqlite3):"
+echo " sqlite3 shelley.db \"INSERT INTO conversations (conversation_id, slug) VALUES ('test-123', 'manual-test');\""
+echo
+echo " Then test chat:"
+echo " curl -X POST -H 'Content-Type: application/json' -d '{\"message\": \"Hello, how are you?\"}' $BASE_URL/conversation/test-123/chat"
+echo
+echo " And get messages:"
+echo " curl $BASE_URL/conversation/test-123"
+echo
+echo " And test streaming:"
+echo " curl $BASE_URL/conversation/test-123/stream"
+echo
+
+echo "4. Instructions for testing with Anthropic API:"
+echo " 1. Set ANTHROPIC_API_KEY environment variable with a valid key"
+echo " 2. Start server: cd cmd/shelley && ./shelley --port=$PORT"
+echo " 3. Create a conversation and send messages as shown above"
+echo
+
+echo "5. Testing server responsiveness..."
+echo " If server is running, this should return an empty conversations list:"
+make_request "GET" "$BASE_URL/conversations?limit=1"
+
+echo "=== Manual test complete ==="
+echo "For full testing with real conversations, use the commands shown above."
@@ -0,0 +1 @@
+22
@@ -0,0 +1,5 @@
+{
+ "tabWidth": 2,
+ "useTabs": false,
+ "printWidth": 100
+}
@@ -0,0 +1,101 @@
+# Shelley E2E Tests with Playwright
+
+This directory contains end-to-end tests for the Shelley web interface using Playwright.
+
+## Features
+
+- **Mobile-focused testing**: Primary focus on mobile viewports (iPhone, Pixel)
+- **Predictable LLM**: Uses the predictable LLM model for deterministic testing
+- **Screenshot capture**: Automatic screenshot generation for visual inspection
+- **Tool testing**: Tests bash tool, think tool, and patch tool interactions
+- **Multi-browser support**: Tests across Chrome, Firefox, Safari, and mobile variants
+
+## Running Tests
+
+### Install Dependencies
+```bash
+cd ui/
+npm install
+npx playwright install
+```
+
+### Run All Tests
+```bash
+npm run test:e2e
+```
+
+### Run Specific Tests
+```bash
+# Run only mobile Chrome tests
+npm run test:e2e -- --project="Mobile Chrome"
+
+# Run specific test
+npm run test:e2e -- --grep "should load the main page"
+
+# Run with headed browser (visible)
+npm run test:e2e:headed
+
+# Open UI mode
+npm run test:e2e:ui
+```
+
+### Debug Failed Tests
+```bash
+# View HTML report
+npx playwright show-report
+
+# View screenshots
+ls -la test-results/*/
+```
+
+## Test Structure
+
+### Basic Interactions (`basic-interactions.spec.ts`)
+- Page loading
+- Starting conversations
+- Tool usage
+- Conversation history
+- Responsive design
+
+### Mobile-Focused Tests (`mobile-focused.spec.ts`)
+- Mobile layout verification
+- Touch interactions
+- Text input on mobile
+- Scrolling behavior
+- Mobile-specific UI patterns
+
+### Predictable Behavior (`predictable-behavior.spec.ts`)
+- Deterministic LLM responses
+- Tool interaction patterns
+- Error handling
+- Multi-turn conversations
+
+## Screenshot Inspection
+
+Screenshots are automatically saved in `test-results/` directory:
+- Failed tests: Screenshots at failure point
+- All tests: Screenshots at key interaction points
+- Mobile-optimized: Focus on mobile viewport sizes
+
+## Predictable LLM
+
+The tests use Shelley's predictable LLM model which provides:
+- Consistent responses for the same inputs
+- Deterministic tool usage
+- Predictable conversation flows
+- Special test commands (`echo`, `error`, `tool`)
+
+## Configuration
+
+Playwright configuration is in `playwright.config.ts`:
+- Auto-starts Shelley server with predictable model
+- Configures mobile-first viewports
+- Sets up screenshot and video capture
+- Handles test timeouts and retries
+
+## Tips
+
+1. **Mobile First**: Most tests are designed for mobile viewports
+2. **Screenshots**: Check `e2e/screenshots/` for visual debugging
+3. **Deterministic**: All tests should be repeatable and deterministic
+4. **Fast Feedback**: Tests are designed to fail fast with meaningful errors
@@ -0,0 +1,141 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Conversation Cancellation', () => {
+ test('should cancel long-running command and show cancelled state after reload', async ({ page }) => {
+ // Start the server and navigate to it
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Wait for the message input
+ const input = page.getByTestId('message-input');
+ await expect(input).toBeVisible({ timeout: 30000 });
+
+ // Send a command that will take a long time (sleep 100 seconds)
+ await input.fill('bash: sleep 100');
+
+ const sendButton = page.getByTestId('send-button');
+ await expect(sendButton).toBeVisible();
+ await sendButton.click();
+
+ // Wait for the agent to start working (thinking indicator appears)
+ await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 10000 });
+
+ // Wait a bit for the tool to actually start executing
+ await page.waitForTimeout(500);
+
+ // Verify the cancel button appears when agent is working
+ const cancelButton = page.locator('button:has-text("Cancel")');
+ await expect(cancelButton).toBeVisible();
+
+ // Click the cancel button
+ await cancelButton.click();
+
+ // Wait for cancellation to complete (button should show "Cancelling..." then disappear)
+ await expect(page.locator('button:has-text("Cancelling...")')).toBeVisible({ timeout: 2000 });
+ await expect(cancelButton).not.toBeVisible({ timeout: 5000 });
+
+ // Verify the thinking indicator is gone
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+
+ // Verify we see the cancelled tool result
+ await expect(page.locator('text=/cancelled/i')).toBeVisible({ timeout: 5000 });
+
+ // Verify we see the [Operation cancelled] message
+ await expect(page.locator('text=/\\[Operation cancelled\\]/i')).toBeVisible({ timeout: 5000 });
+
+ // Now reload the page to verify state is preserved
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
+
+ // After reload, the agent should NOT be working
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 2000 });
+
+ // Cancel button should not be visible
+ await expect(page.locator('button:has-text("Cancel")')).not.toBeVisible();
+
+ // The cancelled messages should still be visible
+ await expect(page.locator('text=/cancelled/i')).toBeVisible();
+ await expect(page.locator('text=/\\[Operation cancelled\\]/i')).toBeVisible();
+
+ // Verify we can continue the conversation after cancellation
+ await input.fill('echo: test after cancel');
+ await input.press('Enter');
+
+ // Agent should start working again
+ await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 5000 });
+
+ // Should get a response
+ await expect(page.locator('text=test after cancel')).toBeVisible({ timeout: 10000 });
+
+ // Agent should stop working
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test('should cancel without tool execution (text generation)', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const input = page.getByTestId('message-input');
+ await expect(input).toBeVisible({ timeout: 30000 });
+
+ // Send a command that triggers a delay in text generation
+ await input.fill('delay: 5');
+
+ const sendButton = page.getByTestId('send-button');
+ await sendButton.click();
+
+ // Wait for agent to start working
+ await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 5000 });
+
+ // Wait a moment then cancel
+ await page.waitForTimeout(500);
+
+ const cancelButton = page.locator('button:has-text("Cancel")');
+ await expect(cancelButton).toBeVisible();
+ await cancelButton.click();
+
+ // Wait for cancellation
+ await expect(cancelButton).not.toBeVisible({ timeout: 5000 });
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+
+ // Reload and verify agent is not working
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 2000 });
+ });
+
+ test('should show correct state without reload', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const input = page.getByTestId('message-input');
+ await expect(input).toBeVisible({ timeout: 30000 });
+
+ // Send a long-running command
+ await input.fill('bash: sleep 50');
+
+ const sendButton = page.getByTestId('send-button');
+ await sendButton.click();
+
+ // Wait for agent to start working
+ await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 10000 });
+ await page.waitForTimeout(500);
+
+ // Cancel
+ const cancelButton = page.locator('button:has-text("Cancel")');
+ await cancelButton.click();
+
+ // Agent should stop working immediately (without reload)
+ await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+ await expect(cancelButton).not.toBeVisible();
+
+ // Should be able to send another message immediately
+ await input.fill('echo: after cancel');
+
+ const sendButton2 = page.getByTestId('send-button');
+ await sendButton2.click();
+
+ // Wait for response - use .first() to handle multiple matches
+ await expect(page.locator('text=after cancel').first()).toBeVisible({ timeout: 10000 });
+ });
+});
@@ -0,0 +1,464 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Shelley Conversation Tests', () => {
+ test('can send Hello and get greeting response', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Wait for the message input using improved selector
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible({ timeout: 30000 });
+
+ // Send "Hello" and expect specific predictable response
+ await messageInput.fill('Hello');
+
+ // Find and click the send button using improved selector
+ const sendButton = page.getByTestId('send-button');
+ await expect(sendButton).toBeVisible();
+ await sendButton.click();
+
+ // Wait for the response from the predictable model
+ // The predictable model responds to "Hello" with "Hello! I'm Shelley, your AI assistant. How can I help you today?"
+ await page.waitForFunction(
+ () => {
+ const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify both the user message and assistant response are visible
+ await expect(page.locator('text=Hello').first()).toBeVisible();
+ await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+ });
+
+ test('can use echo command', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send "echo: test message" and expect echo response
+ await messageInput.fill('echo: test message');
+ await sendButton.click();
+
+ // The predictable model should echo back "test message"
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('test message') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify both input and output messages are visible
+ await expect(page.locator('text=echo: test message')).toBeVisible();
+ });
+
+ test('responds differently to lowercase hello', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send "hello" (lowercase) and expect different response
+ await messageInput.fill('hello');
+ await sendButton.click();
+
+ // The predictable model responds to "hello" with "Well, hi there!"
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('Well, hi there!') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify the hello message and response are both visible
+ await expect(page.getByText('Well, hi there!').first()).toBeVisible();
+ });
+
+ test('shows thinking indicator while awaiting response', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('hello');
+ await sendButton.click();
+
+ const thinkingIndicator = page.getByTestId('agent-thinking');
+ await expect(thinkingIndicator).toBeVisible({ timeout: 2000 });
+
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('Well, hi there!') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ await expect(thinkingIndicator).toBeHidden({ timeout: 10000 });
+ });
+
+ test('shows thinking indicator on follow-up messages', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('hello');
+ await sendButton.click();
+
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('Well, hi there!') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ await messageInput.fill('echo: follow up');
+ await sendButton.click();
+
+ const thinkingIndicator = page.getByTestId('agent-thinking');
+ await expect(thinkingIndicator).toBeVisible({ timeout: 2000 });
+
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('follow up') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ await expect(thinkingIndicator).toBeHidden({ timeout: 10000 });
+ });
+
+ test('can use bash tool', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a message that triggers tool use
+ await messageInput.fill('bash: echo "hello world"');
+ await sendButton.click();
+
+ // The predictable model should use the bash tool and show the response
+ await page.waitForFunction(
+ () => {
+ const text = 'I\'ll run the command: echo "hello world"';
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify tool usage appears in the UI with coalesced tool call
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+ // Check that the tool name "bash" is visible
+ await expect(page.locator('text=bash').first()).toBeVisible();
+ });
+
+ test('gives default response for undefined messages', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send an undefined message and expect default response
+ await messageInput.fill('this is an undefined message');
+ await sendButton.click();
+
+ // The predictable model responds to undefined inputs with "edit predictable.go to add a response for that one..."
+ await page.waitForFunction(
+ () => {
+ const text = 'edit predictable.go to add a response for that one...';
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify the undefined message and default response are visible
+ await expect(page.locator('text=this is an undefined message')).toBeVisible();
+ });
+
+ test('conversation persists and displays correctly', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send first message
+ await messageInput.fill('Hello');
+ await sendButton.click();
+
+ // Wait for first response
+ await page.waitForFunction(
+ () => {
+ const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Send second message
+ await messageInput.fill('echo: second message');
+ await sendButton.click();
+
+ // Wait for second response
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('second message') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify both responses are still visible (conversation persists)
+ await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+ await expect(page.locator('text=second message').first()).toBeVisible();
+ });
+
+ test('can send message with Enter key', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible({ timeout: 30000 });
+
+ // Type message and press Enter
+ await messageInput.fill('Hello');
+ await messageInput.press('Enter');
+
+ // Verify response
+ await page.waitForFunction(
+ () => {
+ const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify the Hello message and response are visible
+ await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+ });
+
+ test('handles think tool correctly', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a message that triggers think tool
+ await messageInput.fill('think: I need to analyze this problem');
+ await sendButton.click();
+
+ // The predictable model should use the think tool
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('Let me think about this.') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify think tool usage appears in the UI
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('text=think').first()).toBeVisible();
+ });
+
+ test('handles patch tool correctly', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a message that triggers patch tool
+ await messageInput.fill('patch: test.txt');
+ await sendButton.click();
+
+ // The predictable model should use the patch tool
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('I\'ll patch the file: test.txt') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify patch tool usage appears in the UI
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('text=patch').first()).toBeVisible();
+ });
+
+ test('displays tool results with collapsible details', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a bash command that will show tool results
+ await messageInput.fill('bash: echo "testing tool results"');
+ await sendButton.click();
+
+ // Wait for the tool call to appear
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Check for bash tool header (collapsible element)
+ const bashToolHeader = page.locator('.bash-tool-header');
+ await expect(bashToolHeader.first()).toBeVisible({ timeout: 10000 });
+ });
+
+ test('handles multiple consecutive tool calls', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // First tool call: bash
+ await messageInput.fill('bash: echo "first command"');
+ await sendButton.click();
+
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Second tool call: think
+ await messageInput.fill('think: analyzing the output');
+ await sendButton.click();
+
+ // Wait for at least 2 tool calls
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 2,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Third tool call: patch
+ await messageInput.fill('patch: example.txt');
+ await sendButton.click();
+
+ // Wait for at least 3 tool calls
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 3,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify all the specific messages we sent are visible
+ await expect(page.locator('text=bash: echo "first command"')).toBeVisible();
+ await expect(page.locator('text=think: analyzing the output')).toBeVisible();
+ await expect(page.locator('text=patch: example.txt')).toBeVisible();
+
+ // Verify all tool types are visible
+ await expect(page.locator('text=bash').first()).toBeVisible();
+ await expect(page.locator('text=think').first()).toBeVisible();
+ await expect(page.locator('text=patch').first()).toBeVisible();
+ });
+});
+
+ test('coalesces tool calls - shows tool result with details', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a bash command to trigger tool use
+ await messageInput.fill('bash: echo "hello world"');
+ await sendButton.click();
+
+ // Wait for the tool result to appear
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Verify the bash tool header is visible
+ await expect(page.locator('.bash-tool-header').first()).toBeVisible();
+
+ // Verify bash tool shows command
+ await expect(page.locator('.bash-tool-command').first()).toBeVisible();
+ });
+
+ test('coalesces tool calls - displays agent text and tool separately', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a bash command
+ await messageInput.fill('bash: pwd');
+ await sendButton.click();
+
+ // Wait for tool result
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Verify agent message is shown ("I'll run the command: pwd")
+ await expect(page.locator('text=I\'ll run the command: pwd').first()).toBeVisible();
+
+ // Verify tool result is shown separately as coalesced tool call
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible();
+ await expect(page.locator('text=bash').first()).toBeVisible();
+ });
+
+ test('handles sequential tool calls', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // First tool call
+ await messageInput.fill('bash: echo "first"');
+ await sendButton.click();
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Second tool call
+ await messageInput.fill('bash: echo "second"');
+ await sendButton.click();
+
+ // Wait for the second tool result
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 2,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify both tool calls are displayed
+ const toolCalls = page.locator('[data-testid="tool-call-completed"]');
+ expect(await toolCalls.count()).toBeGreaterThanOrEqual(2);
+ });
+
+ test('displays LLM error message in UI', async ({ page }) => {
+ // Clear any existing data by navigating to root (which should show empty state)
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Wait for the empty state or message input
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible({ timeout: 30000 });
+
+ const sendButton = page.getByTestId('send-button');
+
+ // Send a message that triggers an error in the predictable LLM
+ await messageInput.fill('error: test error message');
+ await sendButton.click();
+
+ // Wait for the error message to appear in the UI
+ await page.waitForFunction(
+ () => {
+ const text = 'LLM request failed: predictable error: test error message';
+ return document.body.textContent?.includes(text) ?? false;
+ },
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify error message is visible with error styling
+ const errorMessage = page.locator('[role="alert"]');
+ await expect(errorMessage).toBeVisible({ timeout: 10000 });
+
+ // Verify the error text is displayed
+ await expect(page.locator('text=LLM request failed: predictable error: test error message')).toBeVisible();
+
+ // Verify error label is shown in the message header
+ await expect(page.locator('[role="alert"]').locator('text=Error')).toBeVisible();
+ });
@@ -0,0 +1,198 @@
+import { test, expect } from '@playwright/test';
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+
+test.describe('File Upload via Paste and Drag', () => {
+ let testImagePath: string;
+
+ test.beforeAll(async () => {
+ // Create a minimal valid PNG file for testing
+ const pngHeader = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length and type
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions
+ 0x08, 0x02, 0x00, 0x00, 0x00, // 8-bit RGB
+ 0x90, 0x77, 0x53, 0xde, // CRC
+ 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
+ 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00,
+ 0x05, 0xfe, 0x02, 0xfe,
+ 0xa3, 0x6c, 0x9e, 0x15, // CRC
+ 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND chunk
+ 0xae, 0x42, 0x60, 0x82, // CRC
+ ]);
+
+ testImagePath = path.join(os.tmpdir(), 'test-image.png');
+ fs.writeFileSync(testImagePath, pngHeader);
+ });
+
+ test.afterAll(async () => {
+ // Clean up test image
+ if (testImagePath && fs.existsSync(testImagePath)) {
+ fs.unlinkSync(testImagePath);
+ }
+ });
+
+ test('shows drop overlay when dragging file over input container', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const inputContainer = page.locator('.message-input-container');
+ await expect(inputContainer).toBeVisible();
+
+ // Start a drag operation
+ // Unfortunately we can't actually simulate file drag in Playwright directly,
+ // but we can test that the drag-over class is applied correctly via JavaScript
+
+ // Inject a file drag event
+ await page.evaluate(() => {
+ const container = document.querySelector('.message-input-container');
+ if (container) {
+ const dragEnterEvent = new DragEvent('dragenter', {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer: new DataTransfer()
+ });
+ container.dispatchEvent(dragEnterEvent);
+ }
+ });
+
+ // Check that the overlay appears
+ const overlay = page.locator('.drag-overlay');
+ await expect(overlay).toBeVisible();
+ await expect(overlay).toContainText('Drop files here');
+
+ // Dispatch drag leave to hide the overlay
+ await page.evaluate(() => {
+ const container = document.querySelector('.message-input-container');
+ if (container) {
+ const dragLeaveEvent = new DragEvent('dragleave', {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer: new DataTransfer()
+ });
+ container.dispatchEvent(dragLeaveEvent);
+ }
+ });
+
+ // Overlay should be hidden now
+ await expect(overlay).toBeHidden();
+ });
+
+ test('upload endpoint accepts files and returns path', async ({ page, request }) => {
+ // Test the upload endpoint directly
+ const testContent = 'test file content';
+ const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
+
+ const body = [
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="file"; filename="test.txt"',
+ 'Content-Type: text/plain',
+ '',
+ testContent,
+ `--${boundary}--`,
+ ''
+ ].join('\r\n');
+
+ const response = await request.post('/api/upload', {
+ headers: {
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`
+ },
+ data: Buffer.from(body)
+ });
+
+ expect(response.status()).toBe(200);
+ const json = await response.json();
+ expect(json.path).toBeDefined();
+ expect(json.path).toContain('/tmp/shelley-screenshots/');
+ expect(json.path).toContain('.txt');
+ });
+
+ test('uploaded file can be read via /api/read endpoint', async ({ request }) => {
+ // First upload a file
+ const testContent = 'hello from test';
+ const boundary = '----TestBoundary';
+
+ const body = [
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="file"; filename="readable.txt"',
+ 'Content-Type: text/plain',
+ '',
+ testContent,
+ `--${boundary}--`,
+ ''
+ ].join('\r\n');
+
+ const uploadResponse = await request.post('/api/upload', {
+ headers: {
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`
+ },
+ data: Buffer.from(body)
+ });
+
+ expect(uploadResponse.status()).toBe(200);
+ const { path: filePath } = await uploadResponse.json();
+
+ // Now read the file via the read endpoint
+ const readResponse = await request.get(`/api/read?path=${encodeURIComponent(filePath)}`);
+ expect(readResponse.status()).toBe(200);
+
+ const content = await readResponse.text();
+ expect(content).toBe(testContent);
+ });
+
+ test('message input accepts text input normally', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ await messageInput.fill('Hello, this is a test message');
+
+ await expect(messageInput).toHaveValue('Hello, this is a test message');
+ });
+
+ test('simulated file drop shows loading placeholder then file path', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible();
+
+ // Simulate file drop by calling the internal uploadFile function via eval
+ // We'll create a mock file and dispatch events
+ await page.evaluate(async () => {
+ const input = document.querySelector('[data-testid="message-input"]') as HTMLTextAreaElement;
+ if (!input) return;
+
+ // Create a simple file
+ const blob = new Blob(['test content'], { type: 'text/plain' });
+ const file = new File([blob], 'test-drop.txt', { type: 'text/plain' });
+
+ // Create a DataTransfer with the file
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+
+ // Create and dispatch drop event
+ const dropEvent = new DragEvent('drop', {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer: dataTransfer
+ });
+
+ const container = document.querySelector('.message-input-container');
+ if (container) {
+ container.dispatchEvent(dropEvent);
+ }
+ });
+
+ // Wait for the upload to complete (should show loading then path)
+ await page.waitForTimeout(500);
+
+ // After upload, the input should contain a file path reference
+ const inputValue = await messageInput.inputValue();
+
+ // Either the file was uploaded successfully (contains path) or there was an error
+ // Both are acceptable as we're testing the UI flow
+ expect(inputValue).toBeTruthy();
+ });
+});
@@ -0,0 +1,63 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Scroll behavior', () => {
+ test('shows scroll-to-bottom button when scrolled up, auto-scrolls when at bottom', async ({ page }) => {
+ // Navigate to app
+ await page.goto('http://localhost:9000');
+
+ // Wait for the app to load
+ await page.waitForSelector('[data-testid="message-input"]');
+
+ // Send multiple messages to create scrollable content
+ const input = page.locator('[data-testid="message-input"]');
+ const sendButton = page.locator('[data-testid="send-button"]');
+
+ // Send a message that generates multiple tool calls to create enough content
+ await input.fill('tool bash ls');
+ await sendButton.click();
+
+ // Wait for agent to finish
+ await page.waitForSelector('[data-testid="agent-thinking"]', { state: 'hidden', timeout: 10000 });
+
+ // Send more messages to ensure we have scrollable content
+ for (let i = 0; i < 3; i++) {
+ await input.fill(`echo message ${i}`);
+ await sendButton.click();
+ await page.waitForSelector('[data-testid="agent-thinking"]', { state: 'hidden', timeout: 10000 });
+ }
+
+ // Get the messages container
+ const messagesContainer = page.locator('.messages-container');
+
+ // Scroll up to the top
+ await messagesContainer.evaluate((el) => {
+ el.scrollTop = 0;
+ });
+
+ // Wait a moment for scroll event to be processed
+ await page.waitForTimeout(200);
+
+ // Verify scroll-to-bottom button appears
+ const scrollButton = page.locator('.scroll-to-bottom-button');
+ await expect(scrollButton).toBeVisible();
+
+ // Click the button
+ await scrollButton.click();
+
+ // Wait for scroll animation
+ await page.waitForTimeout(500);
+
+ // Button should disappear
+ await expect(scrollButton).not.toBeVisible();
+
+ // Send another message - should auto-scroll since we're at bottom
+ await input.fill('echo final message');
+ await sendButton.click();
+
+ // Wait for response
+ await page.waitForSelector('[data-testid="agent-thinking"]', { timeout: 5000 });
+
+ // Button should not appear since we're following the conversation
+ await expect(scrollButton).not.toBeVisible();
+ });
+});
@@ -0,0 +1,74 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Shelley Smoke Tests', () => {
+ test('page loads successfully', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Just verify the page loads with a title
+ const title = await page.title();
+ expect(title).toBe('Shelley');
+ });
+
+ test('can find message input with proper aria label', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Find the textarea using improved selectors
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible();
+
+ // Verify it has proper aria labeling
+ await expect(messageInput).toHaveAttribute('aria-label', 'Message input');
+ });
+
+ test('can find send button with proper aria label', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // Find the send button using improved selectors
+ const sendButton = page.getByTestId('send-button');
+ await expect(sendButton).toBeVisible();
+
+ // Verify it has proper aria labeling
+ await expect(sendButton).toHaveAttribute('aria-label', 'Send message');
+ });
+
+ test('message input is initially empty and focused', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ await expect(messageInput).toBeVisible();
+
+ // Verify input is empty initially
+ await expect(messageInput).toHaveValue('');
+
+ // Verify placeholder text is present
+ await expect(messageInput).toHaveAttribute('placeholder', /Type your message/);
+ });
+
+ test('send button is disabled when input is empty', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const sendButton = page.getByTestId('send-button');
+
+ // Button should be disabled initially
+ await expect(sendButton).toBeDisabled();
+ });
+
+ test('send button becomes enabled when text is entered', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Enter some text
+ await messageInput.fill('test message');
+
+ // Button should now be enabled
+ await expect(sendButton).toBeEnabled();
+ });
+});
@@ -0,0 +1,178 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Tool Component Verification', () => {
+ test('all tools use custom components, not GenericTool', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ // Send the tool smorgasbord message to trigger all tool types
+ await messageInput.fill('tool smorgasbord');
+ await sendButton.click();
+
+ // Wait for the response text to appear
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('Here\'s a sample of all the tools:') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Wait for all tool calls to complete
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify bash tool uses BashTool component (has bash-tool class)
+ const bashTool = page.locator('.bash-tool').first();
+ await expect(bashTool).toBeVisible();
+ await expect(bashTool.locator('.bash-tool-emoji')).toBeVisible();
+ await expect(bashTool.locator('.bash-tool-command')).toBeVisible();
+
+ // Verify think tool uses ThinkTool component (has tool class with think emoji)
+ const thinkTool = page.locator('.tool').filter({ hasText: 'I\'m thinking about the best approach' });
+ await expect(thinkTool.first()).toBeVisible();
+ await expect(thinkTool.locator('.tool-emoji').filter({ hasText: 'π' }).first()).toBeVisible();
+
+ // Verify patch tool uses PatchTool component (has patch-tool class)
+ const patchTool = page.locator('.patch-tool').first();
+ await expect(patchTool).toBeVisible();
+ await expect(patchTool.locator('.patch-tool-emoji')).toBeVisible();
+
+ // Verify screenshot tool uses ScreenshotTool component (has screenshot-tool class)
+ const screenshotTool = page.locator('.screenshot-tool').first();
+ await expect(screenshotTool).toBeVisible();
+ await expect(screenshotTool.locator('.screenshot-tool-emoji').filter({ hasText: 'π·' })).toBeVisible();
+
+ // Verify keyword_search tool uses KeywordSearchTool component (has tool class with search emoji)
+ const keywordTool = page.locator('.tool').filter({ hasText: 'find all references' });
+ await expect(keywordTool.first()).toBeVisible();
+ await expect(keywordTool.locator('.tool-emoji').filter({ hasText: 'π' }).first()).toBeVisible();
+
+ // Verify browser_navigate tool uses BrowserNavigateTool component (has tool class with globe emoji and URL)
+ const navigateTool = page.locator('.tool').filter({ hasText: 'https://example.com' });
+ await expect(navigateTool.first()).toBeVisible();
+ await expect(navigateTool.locator('.tool-emoji').filter({ hasText: 'π' }).first()).toBeVisible();
+
+ // Verify browser_eval tool uses BrowserEvalTool component (has tool class with lightning emoji)
+ const evalTool = page.locator('.tool').filter({ hasText: 'document.title' });
+ await expect(evalTool.first()).toBeVisible();
+ await expect(evalTool.locator('.tool-emoji').filter({ hasText: 'β‘' }).first()).toBeVisible();
+
+ // Verify read_image tool uses ReadImageTool component (has screenshot-tool class with frame emoji)
+ const readImageTool = page.locator('.screenshot-tool').filter({ hasText: '/tmp/image.png' });
+ await expect(readImageTool.first()).toBeVisible();
+ await expect(readImageTool.locator('.screenshot-tool-emoji').filter({ hasText: 'πΌοΈ' }).first()).toBeVisible();
+
+ // Verify browser_recent_console_logs tool uses BrowserConsoleLogsTool component (has tool class with clipboard emoji)
+ const consoleTool = page.locator('.tool').filter({ hasText: 'console logs' });
+ await expect(consoleTool.first()).toBeVisible();
+ await expect(consoleTool.locator('.tool-emoji').filter({ hasText: 'π' }).first()).toBeVisible();
+
+ // CRITICAL: Verify that GenericTool (gear emoji βοΈ) is NOT used for any of these tools
+ // We check that NO tool has the generic gear icon
+ const genericToolGearEmojis = page.locator('.tool-emoji').filter({ hasText: 'βοΈ' });
+ expect(await genericToolGearEmojis.count()).toBe(0);
+ });
+
+ test('bash tool shows command in header', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('bash: unique-test-command-xyz123');
+ await sendButton.click();
+
+ // Wait for and verify the specific bash tool we just created
+ await page.waitForFunction(
+ () => document.body.textContent?.includes('unique-test-command-xyz123') ?? false,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify bash tool shows the command in the header (collapsed state)
+ const bashToolWithOurCommand = page.locator('.bash-tool').filter({ hasText: 'unique-test-command-xyz123' });
+ await expect(bashToolWithOurCommand).toBeVisible();
+ const commandElement = bashToolWithOurCommand.locator('.bash-tool-command');
+ await expect(commandElement).toBeVisible();
+ const commandText = await commandElement.textContent();
+ expect(commandText).toContain('unique-test-command-xyz123');
+ });
+
+ test('think tool shows thought prefix in header', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('think: This is a long thought that should be truncated in the header display');
+ await sendButton.click();
+
+ await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+ // Verify think tool shows truncated thoughts in the header
+ const thinkTool = page.locator('.tool').filter({ hasText: 'This is a long thought' }).first();
+ await expect(thinkTool.locator('.tool-command')).toBeVisible();
+ // The text should be truncated (50 chars max)
+ const headerText = await thinkTool.locator('.tool-command').textContent();
+ expect(headerText?.startsWith('This is a long thought')).toBe(true);
+ });
+
+ test('browser navigate tool shows URL in header', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('tool smorgasbord');
+ await sendButton.click();
+
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Verify browser_navigate tool shows URL in the header
+ const navigateTool = page.locator('.tool').filter({ hasText: 'https://example.com' }).first();
+ await expect(navigateTool.locator('.tool-command').filter({ hasText: 'https://example.com' })).toBeVisible();
+ });
+
+ test('emoji sizes are consistent across all tools', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ const messageInput = page.getByTestId('message-input');
+ const sendButton = page.getByTestId('send-button');
+
+ await messageInput.fill('tool smorgasbord');
+ await sendButton.click();
+
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+ undefined,
+ { timeout: 30000 }
+ );
+
+ // Get all tool emojis and check their computed font-size
+ const emojiSizes = await page.$$eval(
+ '.tool-emoji, .bash-tool-emoji, .patch-tool-emoji, .screenshot-tool-emoji',
+ (elements) => elements.map(el => window.getComputedStyle(el).fontSize)
+ );
+
+ // All emojis should be 1rem (16px by default)
+ // Check that all sizes are the same
+ const uniqueSizes = new Set(emojiSizes);
+ expect(uniqueSizes.size).toBe(1);
+
+ // Verify the size is 16px (1rem)
+ expect(emojiSizes[0]).toBe('16px');
+ });
+});
@@ -0,0 +1,102 @@
+package ui
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// Dist contains the contents of the built UI under dist/.
+//
+//go:embed dist/*
+var Dist embed.FS
+
+var assets http.FileSystem
+
+func init() {
+ sub, err := fs.Sub(Dist, "dist")
+ if err != nil {
+ // If the build is misconfigured and dist/ is missing, fail fast.
+ panic(err)
+ }
+ assets = http.FS(sub)
+
+ // Check if UI sources are stale compared to the embedded build
+ checkStaleness()
+}
+
+// checkStaleness verifies that the embedded UI build is not stale.
+// If ui/src exists and has files modified after the build, we exit with an error.
+func checkStaleness() {
+ // Read build-info.json from embedded filesystem
+ buildInfoData, err := fs.ReadFile(Dist, "dist/build-info.json")
+ if err != nil {
+ // If build-info.json doesn't exist, the build is old or incomplete.
+ fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
+ fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
+ fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && npm run build\n\n")
+ os.Exit(1)
+ return
+ }
+
+ var buildInfo struct {
+ Timestamp int64 `json:"timestamp"`
+ Date string `json:"date"`
+ SrcDir string `json:"srcDir"`
+ }
+ if err := json.Unmarshal(buildInfoData, &buildInfo); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: failed to parse build-info.json: %v\n", err)
+ return
+ }
+
+ buildTime := time.UnixMilli(buildInfo.Timestamp)
+
+ // Check if source directory exists (we might be in a deployed binary without source)
+ srcDir := buildInfo.SrcDir
+ if srcDir == "" {
+ // Build info doesn't have srcDir, can't check staleness
+ return
+ }
+ if _, err := os.Stat(srcDir); os.IsNotExist(err) {
+ // Source directory doesn't exist, assume we're in production/deployed
+ return
+ }
+
+ // Walk through ui/src and check if any files are newer than the build
+ var newerFiles []string
+ err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() && info.ModTime().After(buildTime) {
+ newerFiles = append(newerFiles, path)
+ }
+ return nil
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: failed to check source file timestamps: %v\n", err)
+ return
+ }
+
+ if len(newerFiles) > 0 {
+ fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
+ fmt.Fprintf(os.Stderr, "Build timestamp: %s\n", buildInfo.Date)
+ fmt.Fprintf(os.Stderr, "\nThe following source files are newer than the build:\n")
+ for _, f := range newerFiles {
+ fmt.Fprintf(os.Stderr, " - %s\n", f)
+ }
+ fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
+ fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && npm run build\n\n")
+ os.Exit(1)
+ }
+}
+
+// Assets returns an http.FileSystem backed by the embedded UI assets.
+func Assets() http.FileSystem {
+ return assets
+}
@@ -0,0 +1,29 @@
+// @ts-check
+
+import eslint from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ ignores: ['dist/', 'node_modules/', '*.config.js'],
+ },
+ {
+ languageOptions: {
+ globals: {
+ // Browser globals
+ window: 'readonly',
+ document: 'readonly',
+ console: 'readonly',
+ setTimeout: 'readonly',
+ fetch: 'readonly',
+ EventSource: 'readonly',
+ HTMLDivElement: 'readonly',
+ HTMLTextAreaElement: 'readonly',
+ Event: 'readonly',
+ KeyboardEvent: 'readonly',
+ },
+ },
+ },
+);
@@ -0,0 +1,4293 @@
+{
+ "name": "shelley-ui",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "shelley-ui",
+ "version": "1.0.0",
+ "dependencies": {
+ "monaco-editor": "^0.44.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.35.0",
+ "@playwright/test": "^1.40.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.43.0",
+ "@typescript-eslint/parser": "^8.43.0",
+ "esbuild": "^0.19.0",
+ "eslint": "^9.35.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "prettier": "^3.6.2",
+ "typescript": "^5.0.0",
+ "typescript-eslint": "^8.43.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
+ "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
+ "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
+ "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
+ "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
+ "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
+ "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
+ "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
+ "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
+ "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
+ "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
+ "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
+ "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
+ "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
+ "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
+ "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
+ "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
+ "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
+ "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
+ "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
+ "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
+ "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+ "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.6",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
+ "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+ "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.37.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
+ "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+ "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
+ "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
+ "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.56.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.26",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
+ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
+ "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/type-utils": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.46.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
+ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
+ "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.46.0",
+ "@typescript-eslint/types": "^8.46.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
+ "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
+ "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
+ "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
+ "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
+ "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.46.0",
+ "@typescript-eslint/tsconfig-utils": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/visitor-keys": "8.46.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
+ "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.46.0",
+ "@typescript-eslint/types": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
+ "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.0",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+ "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
+ "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.0.3",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.6",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.4",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
+ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.19.12",
+ "@esbuild/android-arm": "0.19.12",
+ "@esbuild/android-arm64": "0.19.12",
+ "@esbuild/android-x64": "0.19.12",
+ "@esbuild/darwin-arm64": "0.19.12",
+ "@esbuild/darwin-x64": "0.19.12",
+ "@esbuild/freebsd-arm64": "0.19.12",
+ "@esbuild/freebsd-x64": "0.19.12",
+ "@esbuild/linux-arm": "0.19.12",
+ "@esbuild/linux-arm64": "0.19.12",
+ "@esbuild/linux-ia32": "0.19.12",
+ "@esbuild/linux-loong64": "0.19.12",
+ "@esbuild/linux-mips64el": "0.19.12",
+ "@esbuild/linux-ppc64": "0.19.12",
+ "@esbuild/linux-riscv64": "0.19.12",
+ "@esbuild/linux-s390x": "0.19.12",
+ "@esbuild/linux-x64": "0.19.12",
+ "@esbuild/netbsd-x64": "0.19.12",
+ "@esbuild/openbsd-x64": "0.19.12",
+ "@esbuild/sunos-x64": "0.19.12",
+ "@esbuild/win32-arm64": "0.19.12",
+ "@esbuild/win32-ia32": "0.19.12",
+ "@esbuild/win32-x64": "0.19.12"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.37.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
+ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.0",
+ "@eslint/config-helpers": "^0.4.0",
+ "@eslint/core": "^0.16.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.37.0",
+ "@eslint/plugin-kit": "^0.4.0",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/monaco-editor": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
+ "integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q=="
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
+ "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.56.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
+ "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
+ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.46.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
+ "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.46.0",
+ "@typescript-eslint/parser": "8.46.0",
+ "@typescript-eslint/typescript-estree": "8.46.0",
+ "@typescript-eslint/utils": "8.46.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
@@ -0,0 +1,41 @@
+{
+ "name": "shelley-ui",
+ "version": "1.0.0",
+ "type": "module",
+ "packageManager": "npm@11.6.2",
+ "scripts": {
+ "dev": "esbuild src/main.tsx --bundle --outfile=dist/main.js --servedir=dist --watch",
+ "build": "node scripts/build.js",
+ "clean": "rm -rf dist/*",
+ "lint": "eslint src --ext .ts,.tsx",
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
+ "type-check": "tsc --noEmit",
+ "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
+ "format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
+ "generate-types": "cd .. && go run ./cmd/go2ts.go -o ui/src/generated-types.ts",
+ "test:e2e": "npm run build && playwright test",
+ "test:e2e:headed": "npm run build && playwright test --headed",
+ "test:e2e:ui": "npm run build && playwright test --ui",
+ "test:e2e:debug": "npm run build && playwright test --debug"
+ },
+ "dependencies": {
+ "monaco-editor": "^0.44.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.35.0",
+ "@playwright/test": "^1.40.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^8.43.0",
+ "@typescript-eslint/parser": "^8.43.0",
+ "esbuild": "^0.19.0",
+ "eslint": "^9.35.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "prettier": "^3.6.2",
+ "typescript": "^5.0.0",
+ "typescript-eslint": "^8.43.0"
+ }
+}
@@ -0,0 +1,45 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+export default defineConfig({
+ testDir: './e2e',
+ /* Run tests in files in parallel */
+ fullyParallel: false, // Keep simple for now
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 1 : 0,
+ /* Single worker for predictable test database state */
+ workers: 1,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: process.env.TEST_SERVER_URL || 'http://localhost:9001',
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ /* Take screenshots on failure */
+ screenshot: 'only-on-failure',
+ /* Record video only on failure */
+ video: 'on-first-retry',
+ },
+
+ /* Just test mobile Chrome for simplicity */
+ projects: [
+ {
+ name: 'Mobile Chrome',
+ use: { ...devices['Pixel 5'] },
+ },
+ ],
+
+ /* Run our test server with isolated database */
+ webServer: {
+ command: 'node scripts/test-server.cjs',
+ url: process.env.TEST_SERVER_URL || 'http://localhost:9001',
+ reuseExistingServer: !process.env.CI, // Allow reuse in dev, always fresh in CI
+ timeout: 60000,
+ },
+});
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Get the absolute path to the src directory
+const srcDir = path.resolve(__dirname, '..', 'src');
+
+const buildInfo = {
+ timestamp: Date.now(),
+ date: new Date().toISOString(),
+ srcDir: srcDir
+};
+
+fs.writeFileSync(
+ path.join(__dirname, '..', 'dist', 'build-info.json'),
+ JSON.stringify(buildInfo, null, 2)
+);
+
+console.log('Build info written:', buildInfo.date);
@@ -0,0 +1,76 @@
+import * as esbuild from 'esbuild';
+import * as fs from 'fs';
+
+const isWatch = process.argv.includes('--watch');
+const isProd = !isWatch;
+
+async function build() {
+ try {
+ // Ensure dist directory exists
+ if (!fs.existsSync('dist')) {
+ fs.mkdirSync('dist');
+ }
+
+ // Build Monaco editor worker separately (IIFE format for web worker)
+ console.log('Building Monaco editor worker...');
+ await esbuild.build({
+ entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.worker.js'],
+ bundle: true,
+ outfile: 'dist/editor.worker.js',
+ format: 'iife',
+ minify: isProd,
+ sourcemap: true,
+ });
+
+ // Build Monaco editor as a separate chunk (JS + CSS)
+ console.log('Building Monaco editor bundle...');
+ await esbuild.build({
+ entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.main.js'],
+ bundle: true,
+ outfile: 'dist/monaco-editor.js',
+ format: 'esm',
+ minify: isProd,
+ sourcemap: true,
+ loader: {
+ '.ttf': 'file',
+ },
+ });
+
+ // Build main app - exclude monaco-editor, we'll load it dynamically
+ console.log('Building main application...');
+ const result = await esbuild.build({
+ entryPoints: ['src/main.tsx'],
+ bundle: true,
+ outfile: 'dist/main.js',
+ format: 'esm',
+ minify: isProd,
+ sourcemap: true,
+ metafile: true,
+ external: ['monaco-editor', '/monaco-editor.js'],
+ });
+
+ // Copy static files
+ fs.copyFileSync('src/index.html', 'dist/index.html');
+ fs.copyFileSync('src/styles.css', 'dist/styles.css');
+
+ // Write build info
+ const buildInfo = { timestamp: new Date().toISOString() };
+ fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
+
+ console.log('Build complete!');
+
+ // Show file sizes
+ console.log('\nOutput files:');
+ const files = fs.readdirSync('dist').filter(f => f.endsWith('.js') || f.endsWith('.css') || f.endsWith('.ttf'));
+ for (const file of files.sort()) {
+ const stats = fs.statSync(`dist/${file}`);
+ const sizeKb = (stats.size / 1024).toFixed(1);
+ console.log(` ${file}: ${sizeKb} KB`);
+ }
+ } catch (error) {
+ console.error('Build failed:', error);
+ process.exit(1);
+ }
+}
+
+build();
@@ -0,0 +1,33 @@
+#!/bin/bash
+
+# Script to help inspect Playwright test screenshots
+
+echo "πΈ Shelley E2E Test Screenshots"
+echo "================================="
+
+cd "$(dirname "$0")/.."
+
+# Create screenshots directory if it doesn't exist
+mkdir -p e2e/screenshots
+
+# Check for test results
+if [ -d "test-results" ]; then
+ echo "\nπ Recent test failures:"
+ find test-results -name "*.png" -type f -exec ls -la {} \; | head -10
+else
+ echo "\nβ No test-results directory found. Run tests first:"
+ echo " npm run test:e2e"
+fi
+
+# Check for screenshots in e2e directory
+if [ "$(ls e2e/screenshots/*.png 2>/dev/null | wc -l)" -gt 0 ]; then
+ echo "\nπ· Generated screenshots:"
+ ls -la e2e/screenshots/*.png | head -10
+else
+ echo "\nπ· No screenshots found in e2e/screenshots/"
+fi
+
+echo "\nπ‘ To view screenshots:"
+echo " - Open files directly with an image viewer"
+echo " - Use 'npx playwright show-report' for HTML report"
+echo " - Check test-results/ for failure screenshots"
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+
+// Test server script for Playwright tests
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const { mkdtempSync } = require('fs');
+const { tmpdir } = require('os');
+const net = require('net');
+
+// Function to find an available port starting from a base port
+function getAvailablePort(startPort = 9001) {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.unref();
+ server.on('error', () => {
+ // Try next port
+ resolve(getAvailablePort(startPort + 1));
+ });
+ server.listen(startPort, () => {
+ const port = server.address().port;
+ server.close(() => {
+ resolve(port);
+ });
+ });
+ });
+}
+
+// Create a temporary directory for this test run
+const tempDir = mkdtempSync(path.join(tmpdir(), 'shelley-e2e-'));
+const testDb = path.join(tempDir, 'test.db');
+const testDbShm = testDb + '-shm';
+const testDbWal = testDb + '-wal';
+
+console.log(`Using temporary database: ${testDb}`);
+
+// Get an available port and start the server
+getAvailablePort().then(port => {
+ console.log(`Starting test server on port ${port}`);
+
+ // Start Shelley server with test configuration
+ const serverProcess = spawn('go', [
+ 'run', './cmd/shelley',
+ '--model', 'predictable',
+ '--predictable-only',
+ '--db', testDb,
+ 'serve',
+ '--port', port.toString()
+ ], {
+ cwd: path.join(__dirname, '../..'),
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ PREDICTABLE_DELAY_MS: process.env.PREDICTABLE_DELAY_MS || '400'
+ }
+ });
+
+ // Cleanup function for temporary directory and database files
+ const cleanup = () => {
+ try {
+ // Remove the entire temporary directory and all its contents
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ console.log(`Cleaned up temporary directory: ${tempDir}`);
+ } catch (error) {
+ console.warn(`Failed to clean up temporary directory: ${error.message}`);
+ }
+ };
+
+ // Handle cleanup on exit
+ process.on('SIGINT', () => {
+ console.log('\nShutting down test server...');
+ serverProcess.kill('SIGTERM');
+ cleanup();
+ process.exit(0);
+ });
+
+ process.on('SIGTERM', () => {
+ serverProcess.kill('SIGTERM');
+ cleanup();
+ process.exit(0);
+ });
+
+ serverProcess.on('close', (code) => {
+ console.log(`Test server exited with code ${code}`);
+ cleanup();
+ process.exit(code);
+ });
+}).catch(error => {
+ console.error('Failed to get available port:', error);
+ process.exit(1);
+});
@@ -0,0 +1,59 @@
+#!/bin/bash
+
+# Script to verify that .gitignore is working correctly
+
+echo "π§Ή Verifying .gitignore configuration for Shelley"
+echo "================================================"
+
+cd "$(dirname "$0")/../.."
+
+echo "\nβ
Current git status:"
+git status --porcelain
+
+if [ $? -eq 0 ] && [ -z "$(git status --porcelain)" ]; then
+ echo "β
Working tree is clean"
+else
+ echo "β οΈ Working tree has changes"
+fi
+
+echo "\nπ« Files being ignored by git:"
+git status --ignored --porcelain | grep '^!!' | head -10
+
+echo "\nπ Build directories that should be ignored:"
+for dir in "ui/node_modules" "ui/dist" "ui/test-results" "ui/playwright-report" "bin"; do
+ if [ -d "$dir" ]; then
+ echo " β
$dir (exists and ignored)"
+ else
+ echo " βͺ $dir (doesn't exist)"
+ fi
+done
+
+echo "\nπΎ Database files that should be ignored:"
+for pattern in "*.db" "*.db-shm" "*.db-wal"; do
+ files=$(find . -maxdepth 2 -name "$pattern" 2>/dev/null)
+ if [ -n "$files" ]; then
+ echo " β
Found and ignoring: $pattern"
+ echo "$files" | sed 's/^/ /'
+ else
+ echo " βͺ No $pattern files found"
+ fi
+done
+
+echo "\nπ Playwright outputs that should be ignored:"
+for dir in "ui/test-results" "ui/playwright-report"; do
+ if [ -d "$dir" ]; then
+ echo " β
$dir (exists and ignored)"
+ else
+ echo " βͺ $dir (doesn't exist)"
+ fi
+done
+
+echo "\nπΈ Screenshot directory:"
+if [ -d "ui/e2e/screenshots" ]; then
+ count=$(find ui/e2e/screenshots -name "*.png" 2>/dev/null | wc -l)
+ echo " β
ui/e2e/screenshots exists with $count PNG files (ignored)"
+else
+ echo " β ui/e2e/screenshots missing"
+fi
+
+echo "\nπ― Summary: .gitignore is properly configured to exclude build outputs while preserving source code."
@@ -0,0 +1,257 @@
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import ChatInterface from "./components/ChatInterface";
+import ConversationDrawer from "./components/ConversationDrawer";
+import { Conversation } from "./types";
+import { api } from "./services/api";
+
+// Check if a slug is a generated ID (format: cXXXX where X is alphanumeric)
+function isGeneratedId(slug: string | null): boolean {
+ if (!slug) return true;
+ return /^c[a-z0-9]+$/i.test(slug);
+}
+
+// Get slug from the current URL path (expects /c/<slug> format)
+function getSlugFromPath(): string | null {
+ const path = window.location.pathname;
+ // Check for /c/<slug> format
+ if (path.startsWith("/c/")) {
+ const slug = path.slice(3); // Remove "/c/" prefix
+ if (slug) {
+ return slug;
+ }
+ }
+ return null;
+}
+
+// Update the URL to reflect the current conversation slug
+function updateUrlWithSlug(conversation: Conversation | undefined) {
+ const currentSlug = getSlugFromPath();
+ const newSlug =
+ conversation?.slug && !isGeneratedId(conversation.slug) ? conversation.slug : null;
+
+ if (currentSlug !== newSlug) {
+ if (newSlug) {
+ window.history.replaceState({}, "", `/c/${newSlug}`);
+ } else {
+ window.history.replaceState({}, "", "/");
+ }
+ }
+}
+
+function updatePageTitle(conversation: Conversation | undefined) {
+ const hostname = window.__SHELLEY_INIT__?.hostname;
+ const parts: string[] = [];
+
+ if (conversation?.slug && !isGeneratedId(conversation.slug)) {
+ parts.push(conversation.slug);
+ }
+ if (hostname) {
+ parts.push(hostname);
+ }
+ parts.push("Shelley Agent");
+
+ document.title = parts.join(" - ");
+}
+
+function App() {
+ const [conversations, setConversations] = useState<Conversation[]>([]);
+ const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const initialSlugResolved = useRef(false);
+
+ // Resolve initial slug from URL
+ const resolveInitialSlug = useCallback(async (convs: Conversation[]) => {
+ if (initialSlugResolved.current) return null;
+ initialSlugResolved.current = true;
+
+ const urlSlug = getSlugFromPath();
+ if (!urlSlug) return null;
+
+ // First check if we already have this conversation in our list
+ const existingConv = convs.find((c) => c.slug === urlSlug);
+ if (existingConv) {
+ return existingConv.conversation_id;
+ }
+
+ // Otherwise, try to fetch by slug
+ try {
+ const conv = await api.getConversationBySlug(urlSlug);
+ if (conv) {
+ return conv.conversation_id;
+ }
+ } catch (err) {
+ console.error("Failed to resolve slug:", err);
+ }
+
+ // Slug not found, clear the URL
+ window.history.replaceState({}, "", "/");
+ return null;
+ }, []);
+
+ // Load conversations on mount
+ useEffect(() => {
+ loadConversations();
+ }, []);
+
+ // Update page title and URL when conversation changes
+ useEffect(() => {
+ const currentConv = conversations.find(
+ (conv) => conv.conversation_id === currentConversationId,
+ );
+ updatePageTitle(currentConv);
+ updateUrlWithSlug(currentConv);
+ }, [currentConversationId, conversations]);
+
+ const loadConversations = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const convs = await api.getConversations();
+ setConversations(convs);
+
+ // Try to resolve conversation from URL slug first
+ const slugConvId = await resolveInitialSlug(convs);
+ if (slugConvId) {
+ setCurrentConversationId(slugConvId);
+ } else if (!currentConversationId && convs.length > 0) {
+ // If we have conversations and no current one selected, select the first
+ setCurrentConversationId(convs[0].conversation_id);
+ }
+ // If no conversations exist, leave currentConversationId as null
+ // The UI will show the welcome screen and create conversation on first message
+ } catch (err) {
+ console.error("Failed to load conversations:", err);
+ setError("Failed to load conversations. Please refresh the page.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const startNewConversation = () => {
+ // Just clear the current conversation - a new one will be created when the user sends their first message
+ setCurrentConversationId(null);
+ setDrawerOpen(false);
+ };
+
+ const selectConversation = (conversationId: string) => {
+ setCurrentConversationId(conversationId);
+ setDrawerOpen(false);
+ };
+
+ const updateConversation = (updatedConversation: Conversation) => {
+ setConversations((prev) =>
+ prev.map((conv) =>
+ conv.conversation_id === updatedConversation.conversation_id ? updatedConversation : conv,
+ ),
+ );
+ };
+
+ const handleConversationArchived = (conversationId: string) => {
+ setConversations((prev) => prev.filter((conv) => conv.conversation_id !== conversationId));
+ // If the archived conversation was current, switch to another or clear
+ if (currentConversationId === conversationId) {
+ const remaining = conversations.filter((conv) => conv.conversation_id !== conversationId);
+ setCurrentConversationId(remaining.length > 0 ? remaining[0].conversation_id : null);
+ }
+ };
+
+ const handleConversationUnarchived = (conversation: Conversation) => {
+ // Add the unarchived conversation back to the list
+ setConversations((prev) => [conversation, ...prev]);
+ };
+
+ const handleConversationRenamed = (conversation: Conversation) => {
+ // Update the conversation in the list with the new slug
+ setConversations((prev) =>
+ prev.map((c) => (c.conversation_id === conversation.conversation_id ? conversation : c)),
+ );
+ };
+
+ if (loading && conversations.length === 0) {
+ return (
+ <div className="loading-container">
+ <div className="loading-content">
+ <div className="spinner" style={{ margin: "0 auto 1rem" }}></div>
+ <p className="text-secondary">Loading...</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (error && conversations.length === 0) {
+ return (
+ <div className="error-container">
+ <div className="error-content">
+ <p className="error-message" style={{ marginBottom: "1rem" }}>
+ {error}
+ </p>
+ <button onClick={loadConversations} className="btn-primary">
+ Retry
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ const currentConversation = conversations.find(
+ (conv) => conv.conversation_id === currentConversationId,
+ );
+
+ // Get the CWD from the most recent conversation (first in list, sorted by updated_at desc)
+ const mostRecentCwd = conversations.length > 0 ? conversations[0].cwd : null;
+
+ const handleFirstMessage = async (message: string, model: string, cwd?: string) => {
+ try {
+ const response = await api.sendMessageWithNewConversation({ message, model, cwd });
+ const newConversationId = response.conversation_id;
+
+ // Fetch the new conversation details
+ const updatedConvs = await api.getConversations();
+ setConversations(updatedConvs);
+ setCurrentConversationId(newConversationId);
+ } catch (err) {
+ console.error("Failed to send first message:", err);
+ setError("Failed to send message");
+ throw err;
+ }
+ };
+
+ return (
+ <div className="app-container">
+ {/* Conversations drawer */}
+ <ConversationDrawer
+ isOpen={drawerOpen}
+ onClose={() => setDrawerOpen(false)}
+ conversations={conversations}
+ currentConversationId={currentConversationId}
+ onSelectConversation={selectConversation}
+ onNewConversation={startNewConversation}
+ onConversationArchived={handleConversationArchived}
+ onConversationUnarchived={handleConversationUnarchived}
+ onConversationRenamed={handleConversationRenamed}
+ />
+
+ {/* Main chat interface */}
+ <div className="main-content">
+ <ChatInterface
+ conversationId={currentConversationId}
+ onOpenDrawer={() => setDrawerOpen(true)}
+ onNewConversation={startNewConversation}
+ currentConversation={currentConversation}
+ onConversationUpdate={updateConversation}
+ onFirstMessage={handleFirstMessage}
+ mostRecentCwd={mostRecentCwd}
+ />
+ </div>
+
+ {/* Backdrop for mobile drawer */}
+ {drawerOpen && (
+ <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
+ )}
+ </div>
+ );
+}
+
+export default App;
@@ -0,0 +1,23 @@
+{
+ "name": "Shelley",
+ "short_name": "Shelley",
+ "description": "AI coding assistant",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1f2937",
+ "theme_color": "#1f2937",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
@@ -0,0 +1,12 @@
+# Tool Components
+
+When adding a new specialized tool component (e.g., `FooTool.tsx`), you must register it in **two places**:
+
+1. **ChatInterface.tsx** - Add to the `TOOL_COMPONENTS` map for real-time streaming rendering
+2. **Message.tsx** - Add to the switch statements in `renderContent()` for both `tool_use` and `tool_result` cases
+
+If you only add it to one place, the tool will render inconsistently:
+- Missing from `TOOL_COMPONENTS`: Falls back to generic rendering during streaming, but shows specialized widget after page reload
+- Missing from `Message.tsx`: Shows specialized widget during streaming, but falls back to generic after page reload
+
+Both files need the import statement and the rendering logic for the tool.
@@ -0,0 +1,109 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BashToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown;
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function BashTool({ toolInput, isRunning, toolResult, hasError, executionTime }: BashToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract command from toolInput
+ const command =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "command" in toolInput &&
+ typeof toolInput.command === "string"
+ ? toolInput.command
+ : typeof toolInput === "string"
+ ? toolInput
+ : "";
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ // Check if this was a cancelled operation
+ const isCancelled = hasError && output.toLowerCase().includes("cancel");
+
+ // Truncate command for display
+ const truncateCommand = (cmd: string, maxLen: number = 300) => {
+ if (cmd.length <= maxLen) return cmd;
+ return cmd.substring(0, maxLen) + "...";
+ };
+
+ const displayCommand = truncateCommand(command);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div
+ className="bash-tool"
+ data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+ >
+ <div className="bash-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="bash-tool-summary">
+ <span className={`bash-tool-emoji ${isRunning ? "running" : ""}`}>π οΈ</span>
+ <span className="bash-tool-command">{displayCommand}</span>
+ {isComplete && isCancelled && <span className="bash-tool-cancelled">β cancelled</span>}
+ {isComplete && hasError && !isCancelled && <span className="bash-tool-error">β</span>}
+ {isComplete && !hasError && <span className="bash-tool-success">β</span>}
+ </div>
+ <button
+ className="bash-tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="bash-tool-details">
+ <div className="bash-tool-section">
+ <div className="bash-tool-label">Command:</div>
+ <pre className="bash-tool-code">{command}</pre>
+ </div>
+
+ {isComplete && (
+ <div className="bash-tool-section">
+ <div className="bash-tool-label">
+ Output{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="bash-tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`bash-tool-code ${hasError ? "error" : ""}`}>
+ {output || "(no output)"}
+ </pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default BashTool;
@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserConsoleLogsToolProps {
+ toolName: string; // to distinguish between recent and clear
+ toolInput?: unknown;
+ isRunning?: boolean;
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function BrowserConsoleLogsTool({
+ toolName,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: BrowserConsoleLogsToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ // Determine display text based on tool name and state
+ const getDisplayText = () => {
+ if (isRunning) {
+ return toolName === "browser_console_clear_logs"
+ ? "clearing console..."
+ : "fetching console logs...";
+ }
+ return toolName === "browser_console_clear_logs" ? "clear console" : "console logs";
+ };
+
+ const displayText = getDisplayText();
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">{displayText}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Output{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>
+ {output || "(no output)"}
+ </pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default BrowserConsoleLogsTool;
@@ -0,0 +1,108 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserEvalToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { script: string }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function BrowserEvalTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: BrowserEvalToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract script from toolInput
+ const script =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "script" in toolInput &&
+ typeof toolInput.script === "string"
+ ? toolInput.script
+ : typeof toolInput === "string"
+ ? toolInput
+ : "";
+
+ // Extract result from toolResult
+ const result =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ // Truncate script for display
+ const truncateScript = (scr: string, maxLen: number = 300) => {
+ if (scr.length <= maxLen) return scr;
+ return scr.substring(0, maxLen) + "...";
+ };
+
+ const displayScript = truncateScript(script);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>β‘</span>
+ <span className="tool-command">{displayScript}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">Script:</div>
+ <pre className="tool-code">{script}</pre>
+ </div>
+
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Result{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>
+ {result || "(no result)"}
+ </pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default BrowserEvalTool;
@@ -0,0 +1,107 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserNavigateToolProps {
+ toolInput?: unknown; // { url: string }
+ isRunning?: boolean;
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function BrowserNavigateTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: BrowserNavigateToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract URL from toolInput
+ const url =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "url" in toolInput &&
+ typeof toolInput.url === "string"
+ ? toolInput.url
+ : typeof toolInput === "string"
+ ? toolInput
+ : "";
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ // Truncate URL for display
+ const truncateUrl = (urlStr: string, maxLen: number = 300) => {
+ if (urlStr.length <= maxLen) return urlStr;
+ return urlStr.substring(0, maxLen) + "...";
+ };
+
+ const displayUrl = truncateUrl(url);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">{displayUrl}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">URL:</div>
+ <div className="tool-code">
+ <a href={url} target="_blank" rel="noopener noreferrer">
+ {url}
+ </a>
+ </div>
+ </div>
+
+ {isComplete && output && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Output{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>{output}</pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default BrowserNavigateTool;
@@ -0,0 +1,105 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserResizeToolProps {
+ toolInput?: unknown; // { width: number, height: number }
+ isRunning?: boolean;
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function BrowserResizeTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: BrowserResizeToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract dimensions from toolInput
+ const width =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "width" in toolInput &&
+ typeof (toolInput as { width: unknown }).width === "number"
+ ? (toolInput as { width: number }).width
+ : 0;
+
+ const height =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "height" in toolInput &&
+ typeof (toolInput as { height: unknown }).height === "number"
+ ? (toolInput as { height: number }).height
+ : 0;
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ const isComplete = !isRunning && toolResult !== undefined;
+ const displaySize = width > 0 && height > 0 ? `${width}Γ${height}` : "...";
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">resize {displaySize}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">Dimensions:</div>
+ <div className="tool-code">
+ {width} Γ {height} pixels
+ </div>
+ </div>
+
+ {isComplete && output && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Output{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>{output}</pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default BrowserResizeTool;
@@ -0,0 +1,101 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ChangeDirToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { path: string }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function ChangeDirTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: ChangeDirToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract path from toolInput
+ const path =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "path" in toolInput &&
+ typeof (toolInput as { path: unknown }).path === "string"
+ ? (toolInput as { path: string }).path
+ : "";
+
+ // Get result text
+ const resultText =
+ toolResult
+ ?.map((r) => r.Text)
+ .filter(Boolean)
+ .join("") || "";
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">cd {path || "..."}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">
+ Path:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <div className={`tool-code ${hasError ? "error" : ""}`}>{path || "(no path)"}</div>
+ </div>
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">Result:</div>
+ <div className={`tool-code ${hasError ? "error" : ""}`}>
+ {resultText || "(no output)"}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default ChangeDirTool;
@@ -0,0 +1,1283 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Message, Conversation, StreamResponse, LLMContent } from "../types";
+import { api } from "../services/api";
+import MessageComponent from "./Message";
+import MessageInput from "./MessageInput";
+import DiffViewer from "./DiffViewer";
+import BashTool from "./BashTool";
+import PatchTool from "./PatchTool";
+import ScreenshotTool from "./ScreenshotTool";
+import ThinkTool from "./ThinkTool";
+import KeywordSearchTool from "./KeywordSearchTool";
+import BrowserNavigateTool from "./BrowserNavigateTool";
+import BrowserEvalTool from "./BrowserEvalTool";
+import ReadImageTool from "./ReadImageTool";
+import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
+import ChangeDirTool from "./ChangeDirTool";
+import BrowserResizeTool from "./BrowserResizeTool";
+import DirectoryPickerModal from "./DirectoryPickerModal";
+
+interface ContextUsageBarProps {
+ contextWindowSize: number;
+ maxContextTokens: number;
+}
+
+function ContextUsageBar({ contextWindowSize, maxContextTokens }: ContextUsageBarProps) {
+ const [showPopup, setShowPopup] = useState(false);
+ const barRef = useRef<HTMLDivElement>(null);
+
+ const percentage = maxContextTokens > 0 ? (contextWindowSize / maxContextTokens) * 100 : 0;
+ const clampedPercentage = Math.min(percentage, 100);
+
+ const getBarColor = () => {
+ if (percentage >= 90) return "var(--error-text)";
+ if (percentage >= 70) return "var(--warning-text, #f59e0b)";
+ return "var(--blue-text)";
+ };
+
+ const formatTokens = (tokens: number) => {
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}k`;
+ return tokens.toString();
+ };
+
+ const handleClick = () => {
+ setShowPopup(!showPopup);
+ };
+
+ // Close popup when clicking outside
+ useEffect(() => {
+ if (!showPopup) return;
+ const handleClickOutside = (e: MouseEvent) => {
+ if (barRef.current && !barRef.current.contains(e.target as Node)) {
+ setShowPopup(false);
+ }
+ };
+ document.addEventListener("click", handleClickOutside);
+ return () => document.removeEventListener("click", handleClickOutside);
+ }, [showPopup]);
+
+ // Calculate fixed position when popup should be shown
+ const [popupPosition, setPopupPosition] = useState<{ bottom: number; right: number } | null>(
+ null,
+ );
+
+ useEffect(() => {
+ if (showPopup && barRef.current) {
+ const rect = barRef.current.getBoundingClientRect();
+ setPopupPosition({
+ bottom: window.innerHeight - rect.top + 4,
+ right: window.innerWidth - rect.right,
+ });
+ } else {
+ setPopupPosition(null);
+ }
+ }, [showPopup]);
+
+ return (
+ <div ref={barRef}>
+ {showPopup && popupPosition && (
+ <div
+ style={{
+ position: "fixed",
+ bottom: popupPosition.bottom,
+ right: popupPosition.right,
+ padding: "6px 10px",
+ backgroundColor: "var(--bg-secondary)",
+ border: "1px solid var(--border-color)",
+ borderRadius: "4px",
+ fontSize: "12px",
+ color: "var(--text-secondary)",
+ whiteSpace: "nowrap",
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
+ zIndex: 100,
+ }}
+ >
+ {formatTokens(contextWindowSize)} / {formatTokens(maxContextTokens)} (
+ {percentage.toFixed(1)}%) tokens used
+ </div>
+ )}
+ <div
+ className="context-usage-bar"
+ onClick={handleClick}
+ title={`Context: ${formatTokens(contextWindowSize)} / ${formatTokens(maxContextTokens)} tokens (${percentage.toFixed(1)}%)`}
+ >
+ <div
+ className="context-usage-fill"
+ style={{
+ width: `${clampedPercentage}%`,
+ backgroundColor: getBarColor(),
+ }}
+ />
+ </div>
+ </div>
+ );
+}
+
+interface CoalescedToolCallProps {
+ toolName: string;
+ toolInput?: unknown;
+ toolResult?: LLMContent[];
+ toolError?: boolean;
+ toolStartTime?: string | null;
+ toolEndTime?: string | null;
+ hasResult?: boolean;
+ display?: unknown;
+}
+
+// Map tool names to their specialized components.
+// IMPORTANT: When adding a new tool here, also add it to Message.tsx renderContent()
+// for both tool_use and tool_result cases. See AGENT.md in this directory.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
+ bash: BashTool,
+ patch: PatchTool,
+ screenshot: ScreenshotTool,
+ browser_take_screenshot: ScreenshotTool,
+ think: ThinkTool,
+ keyword_search: KeywordSearchTool,
+ browser_navigate: BrowserNavigateTool,
+ browser_eval: BrowserEvalTool,
+ read_image: ReadImageTool,
+ browser_recent_console_logs: BrowserConsoleLogsTool,
+ browser_clear_console_logs: BrowserConsoleLogsTool,
+ change_dir: ChangeDirTool,
+ browser_resize: BrowserResizeTool,
+};
+
+function CoalescedToolCall({
+ toolName,
+ toolInput,
+ toolResult,
+ toolError,
+ toolStartTime,
+ toolEndTime,
+ hasResult,
+ display,
+}: CoalescedToolCallProps) {
+ // Calculate execution time if available
+ let executionTime = "";
+ if (hasResult && toolStartTime && toolEndTime) {
+ const start = new Date(toolStartTime).getTime();
+ const end = new Date(toolEndTime).getTime();
+ const diffMs = end - start;
+ if (diffMs < 1000) {
+ executionTime = `${diffMs}ms`;
+ } else {
+ executionTime = `${(diffMs / 1000).toFixed(1)}s`;
+ }
+ }
+
+ // Look up the specialized component for this tool
+ const ToolComponent = TOOL_COMPONENTS[toolName];
+ if (ToolComponent) {
+ const props = {
+ toolInput,
+ isRunning: !hasResult,
+ toolResult,
+ hasError: toolError,
+ executionTime,
+ display,
+ // BrowserConsoleLogsTool needs the toolName prop
+ ...(toolName === "browser_recent_console_logs" || toolName === "browser_clear_console_logs"
+ ? { toolName }
+ : {}),
+ };
+ return <ToolComponent {...props} />;
+ }
+
+ const getToolResultSummary = (results: LLMContent[]) => {
+ if (!results || results.length === 0) return "No output";
+
+ const firstResult = results[0];
+ if (firstResult.Type === 2 && firstResult.Text) {
+ // text content
+ const text = firstResult.Text.trim();
+ if (text.length <= 50) return text;
+ return text.substring(0, 47) + "...";
+ }
+
+ return `${results.length} result${results.length > 1 ? "s" : ""}`;
+ };
+
+ const renderContent = (content: LLMContent) => {
+ if (content.Type === 2) {
+ // text
+ return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
+ }
+ return <div className="text-secondary text-sm italic">[Content type {content.Type}]</div>;
+ };
+
+ if (!hasResult) {
+ // Show "running" state
+ return (
+ <div className="message message-tool" data-testid="tool-call-running">
+ <div className="message-content">
+ <div className="tool-running">
+ <div className="tool-running-header">
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+ />
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+ />
+ </svg>
+ <span className="tool-name">Tool: {toolName}</span>
+ <span className="tool-status-running">(running)</span>
+ </div>
+ <div className="tool-input">
+ {typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2)}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ // Show completed state with result
+ const summary = toolResult ? getToolResultSummary(toolResult) : "No output";
+
+ return (
+ <div className="message message-tool" data-testid="tool-call-completed">
+ <div className="message-content">
+ <details className={`tool-result-details ${toolError ? "error" : ""}`}>
+ <summary className="tool-result-summary">
+ <div className="tool-result-meta">
+ <div className="flex items-center space-x-2">
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+ />
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+ />
+ </svg>
+ <span className="text-sm font-medium text-blue">{toolName}</span>
+ <span className={`tool-result-status text-xs ${toolError ? "error" : "success"}`}>
+ {toolError ? "β" : "β"} {summary}
+ </span>
+ </div>
+ <div className="tool-result-time">
+ {executionTime && <span>{executionTime}</span>}
+ </div>
+ </div>
+ </summary>
+ <div className="tool-result-content">
+ {/* Show tool input */}
+ <div className="tool-result-section">
+ <div className="tool-result-label">Input:</div>
+ <div className="tool-result-data">
+ {toolInput ? (
+ typeof toolInput === "string" ? (
+ toolInput
+ ) : (
+ JSON.stringify(toolInput, null, 2)
+ )
+ ) : (
+ <span className="text-secondary italic">No input data</span>
+ )}
+ </div>
+ </div>
+
+ {/* Show tool output with header */}
+ <div className={`tool-result-section output ${toolError ? "error" : ""}`}>
+ <div className="tool-result-label">Output{toolError ? " (Error)" : ""}:</div>
+ <div className="space-y-2">
+ {toolResult?.map((result, idx) => (
+ <div key={idx}>{renderContent(result)}</div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </details>
+ </div>
+ </div>
+ );
+}
+
+// Animated "Agent working..." with letter-by-letter bold animation
+function AnimatedWorkingStatus() {
+ const text = "Agent working...";
+ const [boldIndex, setBoldIndex] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setBoldIndex((prev) => (prev + 1) % text.length);
+ }, 100); // 100ms per letter
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+ <span className="status-message animated-working">
+ {text.split("").map((char, idx) => (
+ <span key={idx} className={idx === boldIndex ? "bold-letter" : ""}>
+ {char}
+ </span>
+ ))}
+ </span>
+ );
+}
+
+interface ChatInterfaceProps {
+ conversationId: string | null;
+ onOpenDrawer: () => void;
+ onNewConversation: () => void;
+ currentConversation?: Conversation;
+ onConversationUpdate?: (conversation: Conversation) => void;
+ onFirstMessage?: (message: string, model: string, cwd?: string) => Promise<void>;
+ mostRecentCwd?: string | null;
+}
+
+function ChatInterface({
+ conversationId,
+ onOpenDrawer,
+ onNewConversation,
+ currentConversation,
+ onConversationUpdate,
+ onFirstMessage,
+ mostRecentCwd,
+}: ChatInterfaceProps) {
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const models = window.__SHELLEY_INIT__?.models || [];
+ const [selectedModel, setSelectedModelState] = useState<string>(() => {
+ // First check localStorage for a sticky model preference
+ const storedModel = localStorage.getItem("shelley_selected_model");
+ const initModels = window.__SHELLEY_INIT__?.models || [];
+ // Validate that the stored model exists and is ready
+ if (storedModel) {
+ const modelInfo = initModels.find((m) => m.id === storedModel);
+ if (modelInfo?.ready) {
+ return storedModel;
+ }
+ }
+ // Fall back to server default or first ready model
+ const defaultModel = window.__SHELLEY_INIT__?.default_model;
+ if (defaultModel) {
+ return defaultModel;
+ }
+ const firstReady = initModels.find((m) => m.ready);
+ return firstReady?.id || "claude-sonnet-4.5";
+ });
+ // Wrapper to persist model selection to localStorage
+ const setSelectedModel = (model: string) => {
+ setSelectedModelState(model);
+ localStorage.setItem("shelley_selected_model", model);
+ };
+ const [selectedCwd, setSelectedCwdState] = useState<string>("");
+ const [cwdInitialized, setCwdInitialized] = useState(false);
+ // Wrapper to persist cwd selection to localStorage
+ const setSelectedCwd = (cwd: string) => {
+ setSelectedCwdState(cwd);
+ localStorage.setItem("shelley_selected_cwd", cwd);
+ };
+
+ // Initialize CWD with priority: localStorage > mostRecentCwd > server default
+ useEffect(() => {
+ if (cwdInitialized) return;
+
+ // First check localStorage for a sticky cwd preference
+ const storedCwd = localStorage.getItem("shelley_selected_cwd");
+ if (storedCwd) {
+ setSelectedCwdState(storedCwd);
+ setCwdInitialized(true);
+ return;
+ }
+
+ // Use most recent conversation's CWD if available
+ if (mostRecentCwd) {
+ setSelectedCwdState(mostRecentCwd);
+ setCwdInitialized(true);
+ return;
+ }
+
+ // Fall back to server default
+ const defaultCwd = window.__SHELLEY_INIT__?.default_cwd || "";
+ if (defaultCwd) {
+ setSelectedCwdState(defaultCwd);
+ setCwdInitialized(true);
+ }
+ }, [mostRecentCwd, cwdInitialized]);
+ const [cwdError, setCwdError] = useState<string | null>(null);
+ const [editingModel, setEditingModel] = useState(false);
+ const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
+ // Settings modal removed - configuration moved to status bar for empty conversations
+ const [showOverflowMenu, setShowOverflowMenu] = useState(false);
+ const [showDiffViewer, setShowDiffViewer] = useState(false);
+ const [diffCommentText, setDiffCommentText] = useState("");
+ const [agentWorking, setAgentWorking] = useState(false);
+ const [cancelling, setCancelling] = useState(false);
+ const [contextWindowSize, setContextWindowSize] = useState(0);
+ const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null;
+ const links = window.__SHELLEY_INIT__?.links || [];
+ const hostname = window.__SHELLEY_INIT__?.hostname || "localhost";
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [reconnectAttempts, setReconnectAttempts] = useState(0);
+ const [isDisconnected, setIsDisconnected] = useState(false);
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+ const eventSourceRef = useRef<EventSource | null>(null);
+ const overflowMenuRef = useRef<HTMLDivElement>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const userScrolledRef = useRef(false);
+
+ // Load messages and set up streaming
+ useEffect(() => {
+ if (conversationId) {
+ setAgentWorking(false);
+ loadMessages();
+ setupMessageStream();
+ } else {
+ // No conversation yet, show empty state
+ setMessages([]);
+ setLoading(false);
+ }
+
+ return () => {
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ }
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ };
+ }, [conversationId]);
+
+ // Check scroll position and handle scroll-to-bottom button
+ useEffect(() => {
+ const container = messagesContainerRef.current;
+ if (!container) return;
+
+ const handleScroll = () => {
+ const { scrollTop, scrollHeight, clientHeight } = container;
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
+ setShowScrollToBottom(!isNearBottom);
+ userScrolledRef.current = !isNearBottom;
+ };
+
+ container.addEventListener("scroll", handleScroll);
+ return () => container.removeEventListener("scroll", handleScroll);
+ }, []);
+
+ // Auto-scroll to bottom when new messages arrive (only if user is already at bottom)
+ useEffect(() => {
+ if (!userScrolledRef.current) {
+ scrollToBottom();
+ }
+ }, [messages]);
+
+ // Close overflow menu when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) {
+ setShowOverflowMenu(false);
+ }
+ };
+
+ if (showOverflowMenu) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }
+ }, [showOverflowMenu]);
+
+ const loadMessages = async () => {
+ if (!conversationId) return;
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await api.getConversation(conversationId);
+ setMessages(response.messages ?? []);
+ setAgentWorking(Boolean(response.agent_working));
+ if (typeof response.context_window_size === "number") {
+ setContextWindowSize(response.context_window_size);
+ }
+ if (onConversationUpdate) {
+ onConversationUpdate(response.conversation);
+ }
+ } catch (err) {
+ console.error("Failed to load messages:", err);
+ setError("Failed to load messages");
+ } finally {
+ // Always set loading to false, even if other operations fail
+ setLoading(false);
+ }
+ };
+
+ const setupMessageStream = () => {
+ if (!conversationId) return;
+
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ }
+
+ const eventSource = api.createMessageStream(conversationId);
+ eventSourceRef.current = eventSource;
+
+ eventSource.onmessage = (event) => {
+ try {
+ const streamResponse: StreamResponse = JSON.parse(event.data);
+ const incomingMessages = Array.isArray(streamResponse.messages)
+ ? streamResponse.messages
+ : [];
+
+ // Merge new messages without losing existing ones.
+ // If no new messages (e.g., only conversation/slug update), keep existing list.
+ if (incomingMessages.length > 0) {
+ setMessages((prev) => {
+ const byId = new Map<string, Message>();
+ for (const m of prev) byId.set(m.message_id, m);
+ for (const m of incomingMessages) byId.set(m.message_id, m);
+ // Preserve original order, then append truly new ones in the order received
+ const result: Message[] = [];
+ for (const m of prev) result.push(byId.get(m.message_id)!);
+ for (const m of incomingMessages) {
+ if (!prev.find((p) => p.message_id === m.message_id)) result.push(m);
+ }
+ return result;
+ });
+ }
+
+ // Update conversation data if provided
+ if (onConversationUpdate) {
+ onConversationUpdate(streamResponse.conversation);
+ }
+
+ if (typeof streamResponse.agent_working === "boolean") {
+ setAgentWorking(streamResponse.agent_working);
+ }
+
+ if (typeof streamResponse.context_window_size === "number") {
+ setContextWindowSize(streamResponse.context_window_size);
+ }
+ } catch (err) {
+ console.error("Failed to parse message stream data:", err);
+ }
+ };
+
+ eventSource.onerror = (event) => {
+ console.warn("Message stream error (will retry):", event);
+ // Close and retry after a delay
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ eventSourceRef.current = null;
+ }
+
+ // Backoff delays: 1s, 5s, 10s, then give up
+ const delays = [1000, 5000, 10000];
+
+ setReconnectAttempts((prev) => {
+ const attempts = prev + 1;
+
+ if (attempts > delays.length) {
+ // Give up and show disconnected UI
+ setIsDisconnected(true);
+ return attempts;
+ }
+
+ const delay = delays[attempts - 1];
+ console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`);
+
+ reconnectTimeoutRef.current = window.setTimeout(() => {
+ if (eventSourceRef.current === null) {
+ setupMessageStream();
+ }
+ }, delay);
+
+ return attempts;
+ });
+ };
+
+ eventSource.onopen = () => {
+ console.log("Message stream connected");
+ // Reset reconnect attempts on successful connection
+ setReconnectAttempts(0);
+ setIsDisconnected(false);
+ };
+ };
+
+ const sendMessage = async (message: string) => {
+ if (!message.trim() || sending) return;
+
+ try {
+ setSending(true);
+ setError(null);
+ setAgentWorking(true);
+
+ // If no conversation ID, this is the first message - validate cwd first
+ if (!conversationId && onFirstMessage) {
+ // Validate cwd if provided
+ if (selectedCwd) {
+ const validation = await api.validateCwd(selectedCwd);
+ if (!validation.valid) {
+ throw new Error(`Invalid working directory: ${validation.error}`);
+ }
+ }
+ await onFirstMessage(message.trim(), selectedModel, selectedCwd || undefined);
+ } else if (conversationId) {
+ await api.sendMessage(conversationId, {
+ message: message.trim(),
+ model: selectedModel,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to send message:", err);
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setError(message);
+ setAgentWorking(false);
+ throw err; // Re-throw so MessageInput can preserve the text
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ userScrolledRef.current = false;
+ setShowScrollToBottom(false);
+ };
+
+ const handleManualReconnect = () => {
+ setIsDisconnected(false);
+ setReconnectAttempts(0);
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ reconnectTimeoutRef.current = null;
+ }
+ setupMessageStream();
+ };
+
+ const handleCancel = async () => {
+ if (!conversationId || cancelling) return;
+
+ try {
+ setCancelling(true);
+ await api.cancelConversation(conversationId);
+ setAgentWorking(false);
+ } catch (err) {
+ console.error("Failed to cancel conversation:", err);
+ setError("Failed to cancel. Please try again.");
+ } finally {
+ setCancelling(false);
+ }
+ };
+
+ const getDisplayTitle = () => {
+ return currentConversation?.slug || "Shelley";
+ };
+
+ // Process messages to coalesce tool calls
+ const processMessages = () => {
+ if (messages.length === 0) {
+ return [];
+ }
+
+ interface CoalescedItem {
+ type: "message" | "tool";
+ message?: Message;
+ toolUseId?: string;
+ toolName?: string;
+ toolInput?: unknown;
+ toolResult?: LLMContent[];
+ toolError?: boolean;
+ toolStartTime?: string | null;
+ toolEndTime?: string | null;
+ hasResult?: boolean;
+ display?: unknown;
+ }
+
+ const coalescedItems: CoalescedItem[] = [];
+ const toolResultMap: Record<
+ string,
+ {
+ result: LLMContent[];
+ error: boolean;
+ startTime: string | null;
+ endTime: string | null;
+ }
+ > = {};
+ // Some tool results may be delivered only as display_data (e.g., screenshots)
+ const displayResultSet: Set<string> = new Set();
+ const displayDataMap: Record<string, unknown> = {};
+
+ // First pass: collect all tool results
+ messages.forEach((message) => {
+ // Collect tool_result data from llm_data if present
+ if (message.llm_data) {
+ try {
+ const llmData =
+ typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+ if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+ llmData.Content.forEach((content: LLMContent) => {
+ if (content && content.Type === 6 && content.ToolUseID) {
+ // tool_result
+ toolResultMap[content.ToolUseID] = {
+ result: content.ToolResult || [],
+ error: content.ToolError || false,
+ startTime: content.ToolUseStartTime || null,
+ endTime: content.ToolUseEndTime || null,
+ };
+ }
+ });
+ }
+ } catch (err) {
+ console.error("Failed to parse message LLM data for tool results:", err);
+ }
+ }
+
+ // Also collect tool_use_ids from display_data to mark completion even if llm_data is omitted
+ if (message.display_data) {
+ try {
+ const displays =
+ typeof message.display_data === "string"
+ ? JSON.parse(message.display_data)
+ : message.display_data;
+ if (Array.isArray(displays)) {
+ for (const d of displays) {
+ if (
+ d &&
+ typeof d === "object" &&
+ "tool_use_id" in d &&
+ typeof d.tool_use_id === "string"
+ ) {
+ displayResultSet.add(d.tool_use_id);
+ // Store the display data for this tool use
+ if ("display" in d) {
+ displayDataMap[d.tool_use_id] = d.display;
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error("Failed to parse display_data for tool completion:", err);
+ }
+ }
+ });
+
+ // Second pass: process messages and extract tool uses
+ messages.forEach((message) => {
+ // Skip system messages
+ if (message.type === "system") {
+ return;
+ }
+
+ if (message.type === "error") {
+ coalescedItems.push({ type: "message", message });
+ return;
+ }
+
+ // Check if this is a user message with tool results (skip rendering them as messages)
+ let hasToolResult = false;
+ if (message.llm_data) {
+ try {
+ const llmData =
+ typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+ if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+ hasToolResult = llmData.Content.some((c: LLMContent) => c.Type === 6);
+ }
+ } catch (err) {
+ console.error("Failed to parse message LLM data:", err);
+ }
+ }
+
+ // If it's a user message without tool results, show it
+ if (message.type === "user" && !hasToolResult) {
+ coalescedItems.push({ type: "message", message });
+ return;
+ }
+
+ // If it's a user message with tool results, skip it (we'll handle it via the toolResultMap)
+ if (message.type === "user" && hasToolResult) {
+ return;
+ }
+
+ if (message.llm_data) {
+ try {
+ const llmData =
+ typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+ if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+ // Extract text content and tool uses separately
+ const textContents: LLMContent[] = [];
+ const toolUses: LLMContent[] = [];
+
+ llmData.Content.forEach((content: LLMContent) => {
+ if (content.Type === 2) {
+ // text
+ textContents.push(content);
+ } else if (content.Type === 5) {
+ // tool_use
+ toolUses.push(content);
+ }
+ });
+
+ // If we have text content, add it as a message (but only if it's not empty)
+ const textString = textContents
+ .map((c) => c.Text || "")
+ .join("")
+ .trim();
+ if (textString) {
+ coalescedItems.push({ type: "message", message });
+ }
+
+ // Add tool uses as separate items
+ toolUses.forEach((toolUse) => {
+ const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
+ const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
+ const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
+ coalescedItems.push({
+ type: "tool",
+ toolUseId: toolUse.ID,
+ toolName: toolUse.ToolName,
+ toolInput: toolUse.ToolInput,
+ toolResult: resultData?.result,
+ toolError: resultData?.error,
+ toolStartTime: resultData?.startTime,
+ toolEndTime: resultData?.endTime,
+ hasResult: !!resultData || completedViaDisplay,
+ display: displayData,
+ });
+ });
+ }
+ } catch (err) {
+ console.error("Failed to parse message LLM data:", err);
+ coalescedItems.push({ type: "message", message });
+ }
+ } else {
+ coalescedItems.push({ type: "message", message });
+ }
+ });
+
+ return coalescedItems;
+ };
+
+ const renderMessages = () => {
+ if (messages.length === 0) {
+ const proxyURL = `https://${hostname}/`;
+ return (
+ <div className="empty-state">
+ <div className="empty-state-content">
+ <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
+ Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
+ stuff. If you build a web site with Shelley, you can use exe.dev's proxy features
+ (see{" "}
+ <a
+ href="https://exe.dev/docs/proxy"
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ color: "var(--blue-text)", textDecoration: "underline" }}
+ >
+ docs
+ </a>
+ ) to visit it over the web at{" "}
+ <a
+ href={proxyURL}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ color: "var(--blue-text)", textDecoration: "underline" }}
+ >
+ {proxyURL}
+ </a>
+ .
+ </p>
+ <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
+ Send a message to start the conversation.
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ const coalescedItems = processMessages();
+
+ const rendered = coalescedItems.map((item, index) => {
+ if (item.type === "message" && item.message) {
+ return <MessageComponent key={item.message.message_id} message={item.message} />;
+ } else if (item.type === "tool") {
+ return (
+ <CoalescedToolCall
+ key={item.toolUseId || `tool-${index}`}
+ toolName={item.toolName || "Unknown Tool"}
+ toolInput={item.toolInput}
+ toolResult={item.toolResult}
+ toolError={item.toolError}
+ toolStartTime={item.toolStartTime}
+ toolEndTime={item.toolEndTime}
+ hasResult={item.hasResult}
+ display={item.display}
+ />
+ );
+ }
+ return null;
+ });
+
+ return rendered;
+ };
+
+ return (
+ <div className="full-height flex flex-col">
+ {/* Header */}
+ <div className="header">
+ <div className="header-left">
+ <button
+ onClick={onOpenDrawer}
+ className="btn-icon hide-on-desktop"
+ aria-label="Open conversations"
+ >
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 6h16M4 12h16M4 18h16"
+ />
+ </svg>
+ </button>
+
+ <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
+ {getDisplayTitle()}
+ </h1>
+ </div>
+
+ <div className="header-actions">
+ {/* Green + icon in circle for new conversation */}
+ <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ </button>
+
+ {/* Overflow menu */}
+ <div ref={overflowMenuRef} style={{ position: "relative" }}>
+ <button
+ onClick={() => setShowOverflowMenu(!showOverflowMenu)}
+ className="btn-icon"
+ aria-label="More options"
+ >
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
+ />
+ </svg>
+ </button>
+
+ {showOverflowMenu && (
+ <div className="overflow-menu">
+ {/* Diffs button - show when we have a CWD */}
+ {(currentConversation?.cwd || selectedCwd) && (
+ <button
+ onClick={() => {
+ setShowOverflowMenu(false);
+ setShowDiffViewer(true);
+ }}
+ className="overflow-menu-item"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+ />
+ </svg>
+ Diffs
+ </button>
+ )}
+ {terminalURL && (
+ <button
+ onClick={() => {
+ setShowOverflowMenu(false);
+ window.open(terminalURL, "_blank");
+ }}
+ className="overflow-menu-item"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
+ />
+ </svg>
+ Terminal
+ </button>
+ )}
+ {links.map((link, index) => (
+ <button
+ key={index}
+ onClick={() => {
+ setShowOverflowMenu(false);
+ window.open(link.url, "_blank");
+ }}
+ className="overflow-menu-item"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d={
+ link.icon_svg ||
+ "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
+ }
+ />
+ </svg>
+ {link.title}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Messages area */}
+ {/* Messages area with scroll-to-bottom button wrapper */}
+ <div className="messages-area-wrapper">
+ <div className="messages-container scrollable" ref={messagesContainerRef}>
+ {loading ? (
+ <div className="flex items-center justify-center full-height">
+ <div className="spinner"></div>
+ </div>
+ ) : (
+ <div className="messages-list">
+ {renderMessages()}
+
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* Scroll to bottom button - outside scrollable area */}
+ {showScrollToBottom && (
+ <button
+ className="scroll-to-bottom-button"
+ onClick={scrollToBottom}
+ aria-label="Scroll to bottom"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1.25rem", height: "1.25rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 14l-7 7m0 0l-7-7m7 7V3"
+ />
+ </svg>
+ </button>
+ )}
+ </div>
+
+ {/* Unified Status Bar */}
+ <div className="status-bar">
+ <div className="status-bar-content">
+ {isDisconnected ? (
+ // Disconnected state
+ <>
+ <span className="status-message status-warning">Disconnected</span>
+ <button
+ onClick={handleManualReconnect}
+ className="status-button status-button-primary"
+ >
+ Retry
+ </button>
+ </>
+ ) : error ? (
+ // Error state
+ <>
+ <span className="status-message status-error">{error}</span>
+ <button onClick={() => setError(null)} className="status-button status-button-text">
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </>
+ ) : agentWorking && conversationId ? (
+ // Agent working - show status with stop button and context bar
+ <div className="status-bar-active">
+ <div className="status-working-group">
+ <AnimatedWorkingStatus />
+ <button
+ onClick={handleCancel}
+ disabled={cancelling}
+ className="status-stop-button"
+ title={cancelling ? "Cancelling..." : "Stop"}
+ >
+ <svg viewBox="0 0 24 24" fill="currentColor">
+ <rect x="6" y="6" width="12" height="12" rx="1" />
+ </svg>
+ <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
+ </button>
+ </div>
+ <ContextUsageBar
+ contextWindowSize={contextWindowSize}
+ maxContextTokens={
+ models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
+ }
+ />
+ </div>
+ ) : // Idle state - show ready message, or configuration for empty conversation
+ !conversationId ? (
+ // Empty conversation - show model (left) and cwd (right)
+ <div className="status-bar-new-conversation">
+ {/* Model selector - far left */}
+ <div
+ className="status-field status-field-model"
+ title="AI model to use for this conversation"
+ >
+ <span className="status-field-label">Model:</span>
+ {editingModel ? (
+ <select
+ id="model-select-status"
+ value={selectedModel}
+ onChange={(e) => setSelectedModel(e.target.value)}
+ onBlur={() => setEditingModel(false)}
+ disabled={sending}
+ className="status-select"
+ autoFocus
+ >
+ {models.map((model) => (
+ <option key={model.id} value={model.id} disabled={!model.ready}>
+ {model.id} {!model.ready ? "(not ready)" : ""}
+ </option>
+ ))}
+ </select>
+ ) : (
+ <button
+ className="status-chip"
+ onClick={() => setEditingModel(true)}
+ disabled={sending}
+ >
+ {selectedModel}
+ </button>
+ )}
+ </div>
+
+ {/* CWD indicator - far right */}
+ <div
+ className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
+ title={cwdError || "Working directory for file operations"}
+ >
+ <span className="status-field-label">Dir:</span>
+ <button
+ className={`status-chip${cwdError ? " status-chip-error" : ""}`}
+ onClick={() => setShowDirectoryPicker(true)}
+ disabled={sending}
+ >
+ {selectedCwd || "(no cwd)"}
+ </button>
+ </div>
+ </div>
+ ) : (
+ // Active conversation - show Ready + context bar
+ <div className="status-bar-active">
+ <span className="status-message status-ready">Ready</span>
+ <ContextUsageBar
+ contextWindowSize={contextWindowSize}
+ maxContextTokens={
+ models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
+ }
+ />
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Message input */}
+ {/* Message input */}
+ <MessageInput
+ key={conversationId || "new"}
+ onSend={sendMessage}
+ disabled={sending || loading}
+ autoFocus={true}
+ injectedText={diffCommentText}
+ onClearInjectedText={() => setDiffCommentText("")}
+ persistKey={conversationId || "new-conversation"}
+ />
+
+ {/* Directory Picker Modal */}
+ <DirectoryPickerModal
+ isOpen={showDirectoryPicker}
+ onClose={() => setShowDirectoryPicker(false)}
+ onSelect={(path) => {
+ setSelectedCwd(path);
+ setCwdError(null);
+ }}
+ initialPath={selectedCwd}
+ />
+
+ {/* Diff Viewer */}
+ <DiffViewer
+ cwd={currentConversation?.cwd || selectedCwd}
+ isOpen={showDiffViewer}
+ onClose={() => setShowDiffViewer(false)}
+ onCommentTextChange={setDiffCommentText}
+ />
+ </div>
+ );
+}
+
+export default ChatInterface;
@@ -0,0 +1,105 @@
+import React from "react";
+
+interface ContextMenuProps {
+ x: number;
+ y: number;
+ onClose: () => void;
+ items: ContextMenuItem[];
+}
+
+interface ContextMenuItem {
+ label: string;
+ icon: React.ReactNode;
+ onClick: () => void;
+}
+
+function ContextMenu({ x, y, onClose, items }: ContextMenuProps) {
+ // Clamp menu within viewport
+ const vw = typeof window !== "undefined" ? window.innerWidth : 0;
+ const vh = typeof window !== "undefined" ? window.innerHeight : 0;
+ const menuWidth = 200;
+ const menuHeight = items.length * 44 + 8; // approximate height
+
+ const clampedX = Math.max(8, Math.min(x, vw - menuWidth - 8));
+ const clampedY = Math.max(8, Math.min(y, vh - menuHeight - 8));
+
+ // Close on any click outside (handled by parent)
+ React.useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (!target.closest("[data-context-menu]")) {
+ onClose();
+ }
+ };
+
+ // Use capture phase to ensure we catch the click before other handlers
+ document.addEventListener("mousedown", handleClickOutside, true);
+ return () => document.removeEventListener("mousedown", handleClickOutside, true);
+ }, [onClose]);
+
+ // Close on escape key
+ React.useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [onClose]);
+
+ return (
+ <div
+ data-context-menu
+ style={{
+ position: "fixed",
+ left: `${clampedX}px`,
+ top: `${clampedY}px`,
+ backgroundColor: "#ffffff",
+ border: "1px solid #e5e7eb",
+ borderRadius: "6px",
+ boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
+ zIndex: 10000,
+ minWidth: `${menuWidth}px`,
+ padding: "4px 0",
+ }}
+ >
+ {items.map((item, index) => (
+ <button
+ key={index}
+ onClick={() => {
+ item.onClick();
+ onClose();
+ }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
+ width: "100%",
+ padding: "10px 16px",
+ border: "none",
+ backgroundColor: "transparent",
+ cursor: "pointer",
+ fontSize: "14px",
+ color: "#1f2937",
+ textAlign: "left",
+ transition: "background-color 0.1s",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = "#f3f4f6";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "transparent";
+ }}
+ >
+ <span style={{ display: "flex", alignItems: "center", width: "20px", height: "20px" }}>
+ {item.icon}
+ </span>
+ <span>{item.label}</span>
+ </button>
+ ))}
+ </div>
+ );
+}
+
+export default ContextMenu;
@@ -0,0 +1,451 @@
+import React, { useState, useEffect } from "react";
+import { Conversation } from "../types";
+import { api } from "../services/api";
+
+interface ConversationDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ conversations: Conversation[];
+ currentConversationId: string | null;
+ onSelectConversation: (id: string) => void;
+ onNewConversation: () => void;
+ onConversationArchived?: (id: string) => void;
+ onConversationUnarchived?: (conversation: Conversation) => void;
+ onConversationRenamed?: (conversation: Conversation) => void;
+}
+
+function ConversationDrawer({
+ isOpen,
+ onClose,
+ conversations,
+ currentConversationId,
+ onSelectConversation,
+ onNewConversation,
+ onConversationArchived,
+ onConversationUnarchived,
+ onConversationRenamed,
+}: ConversationDrawerProps) {
+ const [showArchived, setShowArchived] = useState(false);
+ const [archivedConversations, setArchivedConversations] = useState<Conversation[]>([]);
+ const [loadingArchived, setLoadingArchived] = useState(false);
+ const [editingId, setEditingId] = useState<string | null>(null);
+ const [editingSlug, setEditingSlug] = useState("");
+ const renameInputRef = React.useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ if (showArchived && archivedConversations.length === 0) {
+ loadArchivedConversations();
+ }
+ }, [showArchived]);
+
+ const loadArchivedConversations = async () => {
+ setLoadingArchived(true);
+ try {
+ const archived = await api.getArchivedConversations();
+ setArchivedConversations(archived);
+ } catch (err) {
+ console.error("Failed to load archived conversations:", err);
+ } finally {
+ setLoadingArchived(false);
+ }
+ };
+
+ const formatDate = (timestamp: string) => {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ } else if (diffDays === 1) {
+ return "Yesterday";
+ } else if (diffDays < 7) {
+ return `${diffDays} days ago`;
+ } else {
+ return date.toLocaleDateString();
+ }
+ };
+
+ // Format cwd with ~ for home directory (display only)
+ const formatCwdForDisplay = (cwd: string | null | undefined): string | null => {
+ if (!cwd) return null;
+ const homeDir = window.__SHELLEY_INIT__?.home_dir;
+ if (homeDir && cwd === homeDir) {
+ return "~";
+ }
+ if (homeDir && cwd.startsWith(homeDir + "/")) {
+ return "~" + cwd.slice(homeDir.length);
+ }
+ return cwd;
+ };
+
+ const getConversationPreview = (conversation: Conversation) => {
+ if (conversation.slug) {
+ return conversation.slug;
+ }
+ // Show full conversation ID
+ return conversation.conversation_id;
+ };
+
+ const handleArchive = async (e: React.MouseEvent, conversationId: string) => {
+ e.stopPropagation();
+ try {
+ await api.archiveConversation(conversationId);
+ onConversationArchived?.(conversationId);
+ // Refresh archived list if viewing
+ if (showArchived) {
+ loadArchivedConversations();
+ }
+ } catch (err) {
+ console.error("Failed to archive conversation:", err);
+ }
+ };
+
+ const handleUnarchive = async (e: React.MouseEvent, conversationId: string) => {
+ e.stopPropagation();
+ try {
+ const conversation = await api.unarchiveConversation(conversationId);
+ setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
+ onConversationUnarchived?.(conversation);
+ } catch (err) {
+ console.error("Failed to unarchive conversation:", err);
+ }
+ };
+
+ const handleDelete = async (e: React.MouseEvent, conversationId: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to permanently delete this conversation?")) {
+ return;
+ }
+ try {
+ await api.deleteConversation(conversationId);
+ setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
+ } catch (err) {
+ console.error("Failed to delete conversation:", err);
+ }
+ };
+
+ // Sanitize slug: lowercase, alphanumeric and hyphens only, max 60 chars
+ const sanitizeSlug = (input: string): string => {
+ return input
+ .toLowerCase()
+ .replace(/[\s_]+/g, "-")
+ .replace(/[^a-z0-9-]+/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 60)
+ .replace(/-$/g, "");
+ };
+
+ const handleStartRename = (e: React.MouseEvent, conversation: Conversation) => {
+ e.stopPropagation();
+ setEditingId(conversation.conversation_id);
+ setEditingSlug(conversation.slug || "");
+ // Select all text after render
+ setTimeout(() => renameInputRef.current?.select(), 0);
+ };
+
+ const handleRename = async (conversationId: string) => {
+ const sanitized = sanitizeSlug(editingSlug);
+ if (!sanitized) {
+ setEditingId(null);
+ return;
+ }
+
+ // Check for uniqueness against current conversations
+ const isDuplicate = [...conversations, ...archivedConversations].some(
+ (c) => c.slug === sanitized && c.conversation_id !== conversationId,
+ );
+ if (isDuplicate) {
+ alert("A conversation with this name already exists");
+ return;
+ }
+
+ try {
+ const updated = await api.renameConversation(conversationId, sanitized);
+ onConversationRenamed?.(updated);
+ setEditingId(null);
+ } catch (err) {
+ console.error("Failed to rename conversation:", err);
+ }
+ };
+
+ const handleRenameKeyDown = (e: React.KeyboardEvent, conversationId: string) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleRename(conversationId);
+ } else if (e.key === "Escape") {
+ setEditingId(null);
+ }
+ };
+
+ const displayedConversations = showArchived ? archivedConversations : conversations;
+
+ return (
+ <>
+ {/* Drawer */}
+ <div className={`drawer ${isOpen ? "open" : ""}`}>
+ {/* Header */}
+ <div className="drawer-header">
+ <h2 className="drawer-title">{showArchived ? "Archived" : "Conversations"}</h2>
+ <button
+ onClick={onClose}
+ className="btn-icon hide-on-desktop"
+ aria-label="Close conversations"
+ >
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {/* New conversation button */}
+ {!showArchived && (
+ <div className="drawer-section">
+ <button
+ onClick={onNewConversation}
+ className="btn-primary"
+ style={{
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "0.5rem",
+ }}
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ <span>New Conversation</span>
+ </button>
+ </div>
+ )}
+
+ {/* Conversations list */}
+ <div className="drawer-body scrollable">
+ {loadingArchived && showArchived ? (
+ <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
+ <p>Loading...</p>
+ </div>
+ ) : displayedConversations.length === 0 ? (
+ <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
+ <p>{showArchived ? "No archived conversations" : "No conversations yet"}</p>
+ {!showArchived && (
+ <p className="text-sm" style={{ marginTop: "0.25rem" }}>
+ Start a new conversation to get started
+ </p>
+ )}
+ </div>
+ ) : (
+ <div className="conversation-list">
+ {displayedConversations.map((conversation) => {
+ const isActive = conversation.conversation_id === currentConversationId;
+ return (
+ <div
+ key={conversation.conversation_id}
+ className={`conversation-item ${isActive ? "active" : ""}`}
+ onClick={() => {
+ if (!showArchived) {
+ onSelectConversation(conversation.conversation_id);
+ }
+ }}
+ style={{ cursor: showArchived ? "default" : "pointer" }}
+ >
+ <div style={{ flex: 1, minWidth: 0 }}>
+ {editingId === conversation.conversation_id ? (
+ <input
+ ref={renameInputRef}
+ type="text"
+ value={editingSlug}
+ onChange={(e) => setEditingSlug(e.target.value)}
+ onBlur={() => handleRename(conversation.conversation_id)}
+ onKeyDown={(e) => handleRenameKeyDown(e, conversation.conversation_id)}
+ onClick={(e) => e.stopPropagation()}
+ autoFocus
+ className="conversation-title"
+ style={{
+ width: "100%",
+ background: "transparent",
+ border: "none",
+ borderBottom: "1px solid var(--text-secondary)",
+ outline: "none",
+ padding: 0,
+ font: "inherit",
+ color: "inherit",
+ }}
+ />
+ ) : (
+ <div className="conversation-title">
+ {getConversationPreview(conversation)}
+ </div>
+ )}
+ <div className="conversation-meta">
+ <span className="conversation-date">
+ {formatDate(conversation.updated_at)}
+ </span>
+ {conversation.cwd && (
+ <span className="conversation-cwd" title={conversation.cwd}>
+ {formatCwdForDisplay(conversation.cwd)}
+ </span>
+ )}
+ </div>
+ </div>
+ <div
+ className="conversation-actions"
+ style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
+ >
+ {showArchived ? (
+ <>
+ <button
+ onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Restore"
+ aria-label="Restore conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleDelete(e, conversation.conversation_id)}
+ className="btn-icon-sm btn-danger"
+ title="Delete permanently"
+ aria-label="Delete conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
+ </>
+ ) : (
+ <>
+ <button
+ onClick={(e) => handleStartRename(e, conversation)}
+ className="btn-icon-sm"
+ title="Rename"
+ aria-label="Rename conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleArchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Archive"
+ aria-label="Archive conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+ />
+ </svg>
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Footer with archived toggle */}
+ <div className="drawer-footer">
+ <button
+ onClick={() => setShowArchived(!showArchived)}
+ className="btn-secondary"
+ style={{
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "0.5rem",
+ }}
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ {showArchived ? (
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
+ />
+ ) : (
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+ />
+ )}
+ </svg>
+ <span>{showArchived ? "Back to Conversations" : "View Archived"}</span>
+ </button>
+ </div>
+ </div>
+ </>
+ );
+}
+
+export default ConversationDrawer;
@@ -0,0 +1,757 @@
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import type * as Monaco from "monaco-editor";
+import { api } from "../services/api";
+import { GitDiffInfo, GitFileInfo, GitFileDiff } from "../types";
+
+interface DiffViewerProps {
+ cwd: string;
+ isOpen: boolean;
+ onClose: () => void;
+ onCommentTextChange: (text: string) => void;
+}
+
+// Global Monaco instance - loaded lazily
+let monacoInstance: typeof Monaco | null = null;
+let monacoLoadPromise: Promise<typeof Monaco> | null = null;
+
+function loadMonaco(): Promise<typeof Monaco> {
+ if (monacoInstance) {
+ return Promise.resolve(monacoInstance);
+ }
+ if (monacoLoadPromise) {
+ return monacoLoadPromise;
+ }
+
+ monacoLoadPromise = (async () => {
+ // Configure Monaco environment for web workers before importing
+ const monacoEnv: Monaco.Environment = {
+ getWorkerUrl: () => "/editor.worker.js",
+ };
+ (self as Window).MonacoEnvironment = monacoEnv;
+
+ // Load Monaco CSS if not already loaded
+ if (!document.querySelector('link[href="/monaco-editor.css"]')) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "/monaco-editor.css";
+ document.head.appendChild(link);
+ }
+
+ // Load Monaco from our local bundle (runtime URL, cast to proper types)
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore - dynamic runtime URL import
+ const monaco = (await import("/monaco-editor.js")) as typeof Monaco;
+ monacoInstance = monaco;
+ return monacoInstance;
+ })();
+
+ return monacoLoadPromise;
+}
+
+type ViewMode = "comment" | "edit";
+
+function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerProps) {
+ const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
+ const [gitRoot, setGitRoot] = useState<string | null>(null);
+ const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
+ const [files, setFiles] = useState<GitFileInfo[]>([]);
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
+ const [fileDiff, setFileDiff] = useState<GitFileDiff | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [monacoLoaded, setMonacoLoaded] = useState(false);
+ const [currentChangeIndex, setCurrentChangeIndex] = useState<number>(-1);
+ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
+ const saveTimeoutRef = useRef<number | null>(null);
+ const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
+ const scheduleSaveRef = useRef<(() => void) | null>(null);
+ const contentChangeDisposableRef = useRef<Monaco.IDisposable | null>(null);
+ const [showCommentDialog, setShowCommentDialog] = useState<{
+ line: number;
+ side: "left" | "right";
+ selectedText?: string;
+ startLine?: number;
+ endLine?: number;
+ } | null>(null);
+ const [commentText, setCommentText] = useState("");
+ const [mode, setMode] = useState<ViewMode>("comment");
+ const [selectorsExpanded, setSelectorsExpanded] = useState(false);
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
+ const editorContainerRef = useRef<HTMLDivElement>(null);
+ const editorRef = useRef<Monaco.editor.IStandaloneDiffEditor | null>(null);
+ const monacoRef = useRef<typeof Monaco | null>(null);
+ const modeRef = useRef<ViewMode>(mode);
+
+ // Keep modeRef in sync with mode state and update editor options
+ useEffect(() => {
+ modeRef.current = mode;
+ // Update editor readOnly state when mode changes
+ if (editorRef.current) {
+ const modifiedEditor = editorRef.current.getModifiedEditor();
+ modifiedEditor.updateOptions({ readOnly: mode === "comment" });
+ }
+ }, [mode]);
+
+ // Track viewport size
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ // Load Monaco when viewer opens
+ useEffect(() => {
+ if (isOpen && !monacoLoaded) {
+ loadMonaco()
+ .then((monaco) => {
+ monacoRef.current = monaco;
+ setMonacoLoaded(true);
+ })
+ .catch((err) => {
+ console.error("Failed to load Monaco:", err);
+ setError("Failed to load diff editor");
+ });
+ }
+ }, [isOpen, monacoLoaded]);
+
+ // Load diffs when viewer opens
+ useEffect(() => {
+ if (isOpen && cwd) {
+ loadDiffs();
+ }
+ }, [isOpen, cwd]);
+
+ // Load files when diff is selected
+ useEffect(() => {
+ if (selectedDiff && cwd) {
+ loadFiles(selectedDiff);
+ }
+ }, [selectedDiff, cwd]);
+
+ // Load file diff when file is selected
+ useEffect(() => {
+ if (selectedDiff && selectedFile && cwd) {
+ loadFileDiff(selectedDiff, selectedFile);
+ setCurrentChangeIndex(-1); // Reset change index for new file
+ }
+ }, [selectedDiff, selectedFile, cwd]);
+
+ // Create/update Monaco editor when fileDiff changes
+ useEffect(() => {
+ if (!monacoLoaded || !fileDiff || !editorContainerRef.current || !monacoRef.current) {
+ return;
+ }
+
+ const monaco = monacoRef.current;
+
+ // Dispose previous editor
+ if (editorRef.current) {
+ editorRef.current.dispose();
+ editorRef.current = null;
+ }
+
+ // Get language from file extension
+ const ext = "." + (fileDiff.path.split(".").pop()?.toLowerCase() || "");
+ const languages = monaco.languages.getLanguages();
+ let language = "plaintext";
+ for (const lang of languages) {
+ if (lang.extensions?.includes(ext)) {
+ language = lang.id;
+ break;
+ }
+ }
+
+ // Create models with unique URIs (include timestamp to avoid conflicts)
+ const timestamp = Date.now();
+ const originalUri = monaco.Uri.file(`original-${timestamp}-${fileDiff.path}`);
+ const modifiedUri = monaco.Uri.file(`modified-${timestamp}-${fileDiff.path}`);
+
+ const originalModel = monaco.editor.createModel(fileDiff.oldContent, language, originalUri);
+ const modifiedModel = monaco.editor.createModel(fileDiff.newContent, language, modifiedUri);
+
+ // Create diff editor with mobile-friendly options
+ const diffEditor = monaco.editor.createDiffEditor(editorContainerRef.current, {
+ theme: "vs",
+ readOnly: true, // Always read-only in diff viewer
+ originalEditable: false,
+ automaticLayout: true,
+ renderSideBySide: !isMobile,
+ enableSplitViewResizing: true,
+ renderIndicators: true,
+ renderMarginRevertIcon: false,
+ lineNumbers: isMobile ? "off" : "on",
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ wordWrap: "on",
+ glyphMargin: false, // No glyph margin - click on lines to comment
+ lineDecorationsWidth: isMobile ? 0 : 10,
+ lineNumbersMinChars: isMobile ? 0 : 3,
+ quickSuggestions: false,
+ suggestOnTriggerCharacters: false,
+ lightbulb: { enabled: false },
+ codeLens: false,
+ contextmenu: false,
+ links: false,
+ folding: !isMobile,
+ });
+
+ diffEditor.setModel({
+ original: originalModel,
+ modified: modifiedModel,
+ });
+
+ editorRef.current = diffEditor;
+
+ // Add click handler for commenting - clicking on a line in comment mode opens dialog
+ const modifiedEditor = diffEditor.getModifiedEditor();
+ modifiedEditor.onMouseDown((e: Monaco.editor.IEditorMouseEvent) => {
+ // In comment mode, clicking on line content opens comment dialog
+ const isLineClick =
+ e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
+ e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
+
+ if (isLineClick && modeRef.current === "comment") {
+ const position = e.target.position;
+ if (position) {
+ const model = modifiedEditor.getModel();
+ const selection = modifiedEditor.getSelection();
+ let selectedText = "";
+ let startLine = position.lineNumber;
+ let endLine = position.lineNumber;
+
+ if (selection && !selection.isEmpty() && model) {
+ selectedText = model.getValueInRange(selection);
+ startLine = selection.startLineNumber;
+ endLine = selection.endLineNumber;
+ } else if (model) {
+ selectedText = model.getLineContent(position.lineNumber) || "";
+ }
+
+ setShowCommentDialog({
+ line: startLine,
+ side: "right",
+ selectedText,
+ startLine,
+ endLine,
+ });
+ }
+ }
+ });
+
+ // Add content change listener for auto-save
+ contentChangeDisposableRef.current?.dispose();
+ contentChangeDisposableRef.current = modifiedEditor.onDidChangeModelContent(() => {
+ scheduleSaveRef.current?.();
+ });
+
+ // Cleanup function
+ return () => {
+ contentChangeDisposableRef.current?.dispose();
+ contentChangeDisposableRef.current = null;
+ if (editorRef.current) {
+ editorRef.current.dispose();
+ editorRef.current = null;
+ }
+ };
+ }, [monacoLoaded, fileDiff, isMobile]);
+
+ const loadDiffs = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await api.getGitDiffs(cwd);
+ setDiffs(response.diffs);
+ setGitRoot(response.gitRoot);
+ // Auto-select working changes if non-empty
+ if (response.diffs.length > 0) {
+ const working = response.diffs.find((d) => d.id === "working");
+ if (working && working.filesCount > 0) {
+ setSelectedDiff("working");
+ } else if (response.diffs.length > 1) {
+ setSelectedDiff(response.diffs[1].id);
+ }
+ }
+ } catch (err) {
+ const errStr = String(err);
+ if (errStr.toLowerCase().includes("not a git repository")) {
+ setError(`Not a git repository: ${cwd}`);
+ } else {
+ setError(`Failed to load diffs: ${errStr}`);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadFiles = async (diffId: string) => {
+ try {
+ setLoading(true);
+ setError(null);
+ const filesData = await api.getGitDiffFiles(diffId, cwd);
+ setFiles(filesData || []);
+ if (filesData && filesData.length > 0) {
+ setSelectedFile(filesData[0].path);
+ } else {
+ setSelectedFile(null);
+ setFileDiff(null);
+ }
+ } catch (err) {
+ setError(`Failed to load files: ${err}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadFileDiff = async (diffId: string, filePath: string) => {
+ try {
+ setLoading(true);
+ setError(null);
+ const diffData = await api.getGitFileDiff(diffId, filePath, cwd);
+ setFileDiff(diffData);
+ } catch (err) {
+ setError(`Failed to load file diff: ${err}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAddComment = () => {
+ if (!showCommentDialog || !commentText.trim() || !selectedFile) return;
+
+ // Format and append comment directly to the message input
+ let commentBlock = `**${selectedFile}**`;
+ if (showCommentDialog.startLine !== showCommentDialog.endLine) {
+ commentBlock += ` (lines ${showCommentDialog.startLine}-${showCommentDialog.endLine})`;
+ } else {
+ commentBlock += ` (line ${showCommentDialog.line})`;
+ }
+ commentBlock += ":\n";
+ if (showCommentDialog.selectedText) {
+ commentBlock += "```\n" + showCommentDialog.selectedText + "\n```\n";
+ }
+ commentBlock += commentText + "\n\n";
+
+ onCommentTextChange(commentBlock);
+ setShowCommentDialog(null);
+ setCommentText("");
+ };
+
+ const goToNextFile = useCallback(() => {
+ if (files.length === 0 || !selectedFile) return false;
+ const idx = files.findIndex((f) => f.path === selectedFile);
+ if (idx < files.length - 1) {
+ setSelectedFile(files[idx + 1].path);
+ setCurrentChangeIndex(-1); // Reset to start of new file
+ return true;
+ }
+ return false;
+ }, [files, selectedFile]);
+
+ const goToPreviousFile = useCallback(() => {
+ if (files.length === 0 || !selectedFile) return false;
+ const idx = files.findIndex((f) => f.path === selectedFile);
+ if (idx > 0) {
+ setSelectedFile(files[idx - 1].path);
+ setCurrentChangeIndex(-1); // Will go to last change when file loads
+ return true;
+ }
+ return false;
+ }, [files, selectedFile]);
+
+ const goToNextChange = useCallback(() => {
+ if (!editorRef.current) return;
+ const changes = editorRef.current.getLineChanges();
+ if (!changes || changes.length === 0) {
+ // No changes in this file, try next file
+ goToNextFile();
+ return;
+ }
+
+ const modifiedEditor = editorRef.current.getModifiedEditor();
+ const nextIdx = currentChangeIndex + 1;
+
+ if (nextIdx >= changes.length) {
+ // At end of file, try to go to next file
+ if (goToNextFile()) {
+ return;
+ }
+ // No next file, stay at last change
+ return;
+ }
+
+ const change = changes[nextIdx];
+ const targetLine = change.modifiedStartLineNumber || 1;
+ modifiedEditor.revealLineInCenter(targetLine);
+ modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+ setCurrentChangeIndex(nextIdx);
+ }, [currentChangeIndex, goToNextFile]);
+
+ const goToPreviousChange = useCallback(() => {
+ if (!editorRef.current) return;
+ const changes = editorRef.current.getLineChanges();
+ if (!changes || changes.length === 0) {
+ // No changes in this file, try previous file
+ goToPreviousFile();
+ return;
+ }
+
+ const modifiedEditor = editorRef.current.getModifiedEditor();
+ const prevIdx = currentChangeIndex <= 0 ? -1 : currentChangeIndex - 1;
+
+ if (prevIdx < 0) {
+ // At start of file, try to go to previous file
+ if (goToPreviousFile()) {
+ return;
+ }
+ // No previous file, go to first change
+ const change = changes[0];
+ const targetLine = change.modifiedStartLineNumber || 1;
+ modifiedEditor.revealLineInCenter(targetLine);
+ modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+ setCurrentChangeIndex(0);
+ return;
+ }
+
+ const change = changes[prevIdx];
+ const targetLine = change.modifiedStartLineNumber || 1;
+ modifiedEditor.revealLineInCenter(targetLine);
+ modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+ setCurrentChangeIndex(prevIdx);
+ }, [currentChangeIndex, goToPreviousFile]);
+
+ // Save the current file (in edit mode)
+ const saveCurrentFile = useCallback(async () => {
+ if (
+ !editorRef.current ||
+ !selectedFile ||
+ !fileDiff ||
+ modeRef.current !== "edit" ||
+ !gitRoot
+ ) {
+ return;
+ }
+
+ const modifiedEditor = editorRef.current.getModifiedEditor();
+ const model = modifiedEditor.getModel();
+ if (!model) return;
+
+ const content = model.getValue();
+ const fullPath = gitRoot + "/" + selectedFile;
+
+ try {
+ setSaveStatus("saving");
+ const response = await fetch("/api/write-file", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: fullPath, content }),
+ });
+
+ if (response.ok) {
+ setSaveStatus("saved");
+ setTimeout(() => setSaveStatus("idle"), 2000);
+ } else {
+ setSaveStatus("error");
+ setTimeout(() => setSaveStatus("idle"), 3000);
+ }
+ } catch (err) {
+ console.error("Failed to save:", err);
+ setSaveStatus("error");
+ setTimeout(() => setSaveStatus("idle"), 3000);
+ }
+ }, [selectedFile, fileDiff, gitRoot]);
+
+ // Debounced auto-save
+ const scheduleSave = useCallback(() => {
+ if (modeRef.current !== "edit") return; // Only auto-save in edit mode
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ pendingSaveRef.current = saveCurrentFile;
+ saveTimeoutRef.current = window.setTimeout(() => {
+ pendingSaveRef.current?.();
+ pendingSaveRef.current = null;
+ saveTimeoutRef.current = null;
+ }, 1000);
+ }, [saveCurrentFile]);
+
+ // Keep scheduleSaveRef in sync
+ useEffect(() => {
+ scheduleSaveRef.current = scheduleSave;
+ }, [scheduleSave]);
+
+ // Force immediate save (for Ctrl+S)
+ const saveImmediately = useCallback(() => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = null;
+ }
+ pendingSaveRef.current = null;
+ saveCurrentFile();
+ }, [saveCurrentFile]);
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ if (showCommentDialog) {
+ setShowCommentDialog(null);
+ } else {
+ onClose();
+ }
+ return;
+ }
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
+ e.preventDefault();
+ saveImmediately();
+ return;
+ }
+ if (!e.ctrlKey) return;
+ if (e.key === "j") {
+ e.preventDefault();
+ goToNextFile();
+ } else if (e.key === "k") {
+ e.preventDefault();
+ goToPreviousFile();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [isOpen, goToNextFile, goToPreviousFile, showCommentDialog, onClose, saveImmediately]);
+
+ if (!isOpen) return null;
+
+ const getStatusSymbol = (status: string) => {
+ switch (status) {
+ case "added":
+ return "+";
+ case "deleted":
+ return "-";
+ case "modified":
+ return "~";
+ default:
+ return "";
+ }
+ };
+
+ const currentFileIndex = files.findIndex((f) => f.path === selectedFile);
+ const hasNextFile = currentFileIndex < files.length - 1;
+ const hasPrevFile = currentFileIndex > 0;
+
+ return (
+ <div className="diff-viewer-overlay">
+ <div className="diff-viewer-container">
+ {/* Toast notification */}
+ {saveStatus !== "idle" && (
+ <div className={`diff-viewer-toast diff-viewer-toast-${saveStatus}`}>
+ {saveStatus === "saving" && "πΎ Saving..."}
+ {saveStatus === "saved" && "β
Saved"}
+ {saveStatus === "error" && "β Error saving"}
+ </div>
+ )}
+
+ {/* Header */}
+ <div className="diff-viewer-header">
+ <div className="diff-viewer-header-row">
+ {/* Mode toggle */}
+ <div className="diff-viewer-mode-toggle">
+ <button
+ className={`diff-viewer-mode-btn ${mode === "comment" ? "active" : ""}`}
+ onClick={() => setMode("comment")}
+ title="Comment mode"
+ >
+ π¬
+ </button>
+ <button
+ className={`diff-viewer-mode-btn ${mode === "edit" ? "active" : ""}`}
+ onClick={() => setMode("edit")}
+ title="Edit mode"
+ >
+ βοΈ
+ </button>
+ </div>
+
+ {/* Navigation buttons: <<(prev file) <(prev change) >(next change) >>(next file) */}
+ <div className="diff-viewer-nav-buttons">
+ <button
+ className="diff-viewer-nav-btn"
+ onClick={goToPreviousFile}
+ disabled={!hasPrevFile}
+ title="Previous file"
+ >
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M11 2L5 8l6 6V2zM4 2v12H2V2h2z" />
+ </svg>
+ </button>
+ <button
+ className="diff-viewer-nav-btn"
+ onClick={goToPreviousChange}
+ disabled={!fileDiff}
+ title="Previous change"
+ >
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M10 2L4 8l6 6V2z" />
+ </svg>
+ </button>
+ <button
+ className="diff-viewer-nav-btn"
+ onClick={goToNextChange}
+ disabled={!fileDiff}
+ title="Next change"
+ >
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M6 2l6 6-6 6V2z" />
+ </svg>
+ </button>
+ <button
+ className="diff-viewer-nav-btn"
+ onClick={() => goToNextFile()}
+ disabled={!hasNextFile}
+ title="Next file"
+ >
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M5 2l6 6-6 6V2zM12 2v12h2V2h-2z" />
+ </svg>
+ </button>
+ </div>
+
+ {/* Expand/collapse selectors */}
+ <button
+ className="diff-viewer-expand-btn"
+ onClick={() => setSelectorsExpanded(!selectorsExpanded)}
+ title={selectorsExpanded ? "Collapse selectors" : "Expand selectors"}
+ >
+ {selectorsExpanded ? "β²" : "βΌ"}
+ <span className="diff-viewer-expand-label">
+ {selectedFile ? selectedFile.split("/").pop() : "Select..."}
+ </span>
+ </button>
+
+ <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
+ Γ
+ </button>
+ </div>
+
+ {/* Collapsible selectors */}
+ {selectorsExpanded && (
+ <div className="diff-viewer-selectors">
+ {/* Diff selector */}
+ <select
+ value={selectedDiff || ""}
+ onChange={(e) => setSelectedDiff(e.target.value || null)}
+ className="diff-viewer-select"
+ >
+ <option value="">Choose base...</option>
+ {diffs.map((diff) => {
+ const stats = `${diff.filesCount} files, +${diff.additions}/-${diff.deletions}`;
+ return (
+ <option key={diff.id} value={diff.id}>
+ {diff.id === "working"
+ ? `Working Changes (${stats})`
+ : `${diff.message.slice(0, 40)} (${stats})`}
+ </option>
+ );
+ })}
+ </select>
+
+ {/* File selector */}
+ <select
+ value={selectedFile || ""}
+ onChange={(e) => setSelectedFile(e.target.value || null)}
+ className="diff-viewer-select"
+ disabled={files.length === 0}
+ >
+ <option value="">{files.length === 0 ? "No files" : "Choose file..."}</option>
+ {files.map((file) => (
+ <option key={file.path} value={file.path}>
+ {getStatusSymbol(file.status)} {file.path}
+ {file.additions > 0 && ` (+${file.additions})`}
+ {file.deletions > 0 && ` (-${file.deletions})`}
+ </option>
+ ))}
+ </select>
+ </div>
+ )}
+ </div>
+
+ {/* Error banner */}
+ {error && <div className="diff-viewer-error">{error}</div>}
+
+ {/* Main content */}
+ <div className="diff-viewer-content">
+ {loading && !fileDiff && (
+ <div className="diff-viewer-loading">
+ <div className="spinner"></div>
+ <span>Loading...</span>
+ </div>
+ )}
+
+ {!loading && !monacoLoaded && !error && (
+ <div className="diff-viewer-loading">
+ <div className="spinner"></div>
+ <span>Loading editor...</span>
+ </div>
+ )}
+
+ {!loading && monacoLoaded && !fileDiff && !error && (
+ <div className="diff-viewer-empty">
+ <p>Select a diff and file to view changes.</p>
+ <p className="diff-viewer-hint">Click on line numbers to add comments.</p>
+ </div>
+ )}
+
+ {/* Monaco editor container */}
+ <div
+ ref={editorContainerRef}
+ className="diff-viewer-editor"
+ style={{ display: fileDiff && monacoLoaded ? "block" : "none" }}
+ />
+ </div>
+
+ {/* Comment dialog */}
+ {showCommentDialog && (
+ <div className="diff-viewer-comment-dialog">
+ <h4>
+ Add Comment (Line
+ {showCommentDialog.startLine !== showCommentDialog.endLine
+ ? `s ${showCommentDialog.startLine}-${showCommentDialog.endLine}`
+ : ` ${showCommentDialog.line}`}
+ , {showCommentDialog.side === "left" ? "old" : "new"})
+ </h4>
+ {showCommentDialog.selectedText && (
+ <pre className="diff-viewer-selected-text">{showCommentDialog.selectedText}</pre>
+ )}
+ <textarea
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ placeholder="Enter your comment..."
+ className="diff-viewer-comment-input"
+ autoFocus
+ />
+ <div className="diff-viewer-comment-actions">
+ <button
+ onClick={() => setShowCommentDialog(null)}
+ className="diff-viewer-btn diff-viewer-btn-secondary"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleAddComment}
+ className="diff-viewer-btn diff-viewer-btn-primary"
+ disabled={!commentText.trim()}
+ >
+ Add Comment
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export default DiffViewer;
@@ -0,0 +1,336 @@
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import { api } from "../services/api";
+
+interface DirectoryEntry {
+ name: string;
+ is_dir: boolean;
+}
+
+interface CachedDirectory {
+ path: string;
+ parent: string;
+ entries: DirectoryEntry[];
+}
+
+interface DirectoryPickerModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelect: (path: string) => void;
+ initialPath?: string;
+}
+
+function DirectoryPickerModal({
+ isOpen,
+ onClose,
+ onSelect,
+ initialPath,
+}: DirectoryPickerModalProps) {
+ const [inputPath, setInputPath] = useState(() => {
+ if (!initialPath) return "";
+ return initialPath.endsWith("/") ? initialPath : initialPath + "/";
+ });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ // Cache for directory listings
+ const cacheRef = useRef<Map<string, CachedDirectory>>(new Map());
+
+ // Current directory being displayed (the parent directory of what's being typed)
+ const [displayDir, setDisplayDir] = useState<CachedDirectory | null>(null);
+ // Filter prefix (the part after the last slash that we're filtering by)
+ const [filterPrefix, setFilterPrefix] = useState("");
+
+ // Parse input path into directory and filter prefix
+ const parseInputPath = useCallback((path: string): { dirPath: string; prefix: string } => {
+ if (!path) {
+ return { dirPath: "", prefix: "" };
+ }
+
+ // If path ends with /, we're looking at contents of that directory
+ if (path.endsWith("/")) {
+ return { dirPath: path.slice(0, -1) || "/", prefix: "" };
+ }
+
+ // Otherwise, split into directory and prefix
+ const lastSlash = path.lastIndexOf("/");
+ if (lastSlash === -1) {
+ // No slash, treat as prefix in current directory
+ return { dirPath: "", prefix: path };
+ }
+ if (lastSlash === 0) {
+ // Root directory with prefix
+ return { dirPath: "/", prefix: path.slice(1) };
+ }
+ return {
+ dirPath: path.slice(0, lastSlash),
+ prefix: path.slice(lastSlash + 1),
+ };
+ }, []);
+
+ // Load directory from cache or API
+ const loadDirectory = useCallback(async (path: string): Promise<CachedDirectory | null> => {
+ const normalizedPath = path || "/";
+
+ // Check cache first
+ const cached = cacheRef.current.get(normalizedPath);
+ if (cached) {
+ return cached;
+ }
+
+ // Load from API
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await api.listDirectory(path || undefined);
+ if (result.error) {
+ setError(result.error);
+ return null;
+ }
+
+ const dirData: CachedDirectory = {
+ path: result.path,
+ parent: result.parent,
+ entries: result.entries || [],
+ };
+
+ // Cache it
+ cacheRef.current.set(result.path, dirData);
+
+ return dirData;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load directory");
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Update display when input changes
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const { dirPath, prefix } = parseInputPath(inputPath);
+ setFilterPrefix(prefix);
+
+ // Load the directory
+ loadDirectory(dirPath).then((dir) => {
+ if (dir) {
+ setDisplayDir(dir);
+ setError(null);
+ }
+ });
+ }, [isOpen, inputPath, parseInputPath, loadDirectory]);
+
+ // Initialize when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ if (!initialPath) {
+ setInputPath("");
+ } else {
+ setInputPath(initialPath.endsWith("/") ? initialPath : initialPath + "/");
+ }
+ // Clear cache on open to get fresh data
+ cacheRef.current.clear();
+ }
+ }, [isOpen, initialPath]);
+
+ // Focus input when modal opens (but not on mobile to avoid keyboard popup)
+ useEffect(() => {
+ if (isOpen && inputRef.current) {
+ // Check if mobile device (touch-based)
+ const isMobile = window.matchMedia("(max-width: 768px)").matches || "ontouchstart" in window;
+ if (!isMobile) {
+ inputRef.current.focus();
+ // Move cursor to end
+ const len = inputRef.current.value.length;
+ inputRef.current.setSelectionRange(len, len);
+ }
+ }
+ }, [isOpen]);
+
+ // Filter entries based on prefix (case-insensitive)
+ const filteredEntries =
+ displayDir?.entries.filter((entry) => {
+ if (!filterPrefix) return true;
+ return entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase());
+ }) || [];
+
+ const handleEntryClick = (entry: DirectoryEntry) => {
+ if (entry.is_dir) {
+ const basePath = displayDir?.path || "";
+ const newPath = basePath === "/" ? `/${entry.name}/` : `${basePath}/${entry.name}/`;
+ setInputPath(newPath);
+ }
+ };
+
+ const handleParentClick = () => {
+ if (displayDir?.parent) {
+ const newPath = displayDir.parent === "/" ? "/" : `${displayDir.parent}/`;
+ setInputPath(newPath);
+ }
+ };
+
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleSelect();
+ }
+ };
+
+ const handleSelect = () => {
+ // Use the current directory path for selection
+ const { dirPath } = parseInputPath(inputPath);
+ const selectedPath = inputPath.endsWith("/") ? (dirPath === "/" ? "/" : dirPath) : dirPath;
+ onSelect(selectedPath || displayDir?.path || "");
+ onClose();
+ };
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ // Determine if we should show the parent entry
+ const showParent = displayDir?.parent && displayDir.parent !== "";
+
+ return (
+ <div className="modal-overlay" onClick={handleBackdropClick}>
+ <div className="modal directory-picker-modal">
+ {/* Header */}
+ <div className="modal-header">
+ <h2 className="modal-title">Select Directory</h2>
+ <button onClick={onClose} className="btn-icon" aria-label="Close modal">
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="modal-body directory-picker-body">
+ {/* Path input */}
+ <div className="directory-picker-input-container">
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputPath}
+ onChange={(e) => setInputPath(e.target.value)}
+ onKeyDown={handleInputKeyDown}
+ className="directory-picker-input"
+ placeholder="/path/to/directory"
+ />
+ </div>
+
+ {/* Current directory indicator */}
+ {displayDir && (
+ <div className="directory-picker-current">
+ {displayDir.path}
+ {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+ </div>
+ )}
+
+ {/* Error message */}
+ {error && <div className="directory-picker-error">{error}</div>}
+
+ {/* Loading state */}
+ {loading && (
+ <div className="directory-picker-loading">
+ <div className="spinner spinner-small"></div>
+ <span>Loading...</span>
+ </div>
+ )}
+
+ {/* Directory listing */}
+ {!loading && !error && (
+ <div className="directory-picker-list">
+ {/* Parent directory entry */}
+ {showParent && (
+ <button
+ className="directory-picker-entry directory-picker-entry-parent"
+ onClick={handleParentClick}
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ className="directory-picker-icon"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 17l-5-5m0 0l5-5m-5 5h12"
+ />
+ </svg>
+ <span>..</span>
+ </button>
+ )}
+
+ {/* Directory entries */}
+ {filteredEntries.map((entry) => (
+ <button
+ key={entry.name}
+ className="directory-picker-entry"
+ onClick={() => handleEntryClick(entry)}
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ className="directory-picker-icon"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
+ />
+ </svg>
+ <span>
+ {filterPrefix &&
+ entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()) ? (
+ <>
+ <strong>{entry.name.slice(0, filterPrefix.length)}</strong>
+ {entry.name.slice(filterPrefix.length)}
+ </>
+ ) : (
+ entry.name
+ )}
+ </span>
+ </button>
+ ))}
+
+ {/* Empty state */}
+ {filteredEntries.length === 0 && !showParent && (
+ <div className="directory-picker-empty">
+ {filterPrefix ? "No matching directories" : "No subdirectories"}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="directory-picker-footer">
+ <button className="btn" onClick={onClose}>
+ Cancel
+ </button>
+ <button className="btn-primary" onClick={handleSelect} disabled={loading || !!error}>
+ Select
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export default DirectoryPickerModal;
@@ -0,0 +1,115 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface GenericToolProps {
+ toolName: string;
+
+ // For tool_use (pending state)
+ toolInput?: unknown;
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function GenericTool({
+ toolName,
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: GenericToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Format data for display
+ const formatData = (data: unknown): string => {
+ if (data === undefined || data === null) return "";
+ if (typeof data === "string") return data;
+ try {
+ return JSON.stringify(data, null, 2);
+ } catch {
+ return String(data);
+ }
+ };
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0
+ ? toolResult.map((result) => result.Text || formatData(result)).join("\n")
+ : "";
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>βοΈ</span>
+ <span className="tool-command">{toolName}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ {toolInput !== undefined && (
+ <div className="tool-section">
+ <div className="tool-label">Input:</div>
+ <pre className="tool-code">{formatData(toolInput)}</pre>
+ </div>
+ )}
+
+ {isRunning && (
+ <div className="tool-section">
+ <div className="tool-label">Status:</div>
+ <div className="tool-running-text">running...</div>
+ </div>
+ )}
+
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Output{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>
+ {output || "(no output)"}
+ </pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default GenericTool;
@@ -0,0 +1,124 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface KeywordSearchToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { query: string, search_terms: string[] }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function KeywordSearchTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+}: KeywordSearchToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract query and search terms from toolInput
+ const query =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "query" in toolInput &&
+ typeof toolInput.query === "string"
+ ? toolInput.query
+ : "";
+
+ const searchTerms =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "search_terms" in toolInput &&
+ Array.isArray(toolInput.search_terms)
+ ? toolInput.search_terms
+ : [];
+
+ // Extract output from toolResult
+ const output =
+ toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+ // Truncate search terms for display
+ const truncateSearchTerms = (terms: string[], maxLen: number = 300) => {
+ const joined = terms.join(", ");
+ if (joined.length <= maxLen) return joined;
+ return joined.substring(0, maxLen) + "...";
+ };
+
+ const displayText = query || truncateSearchTerms(searchTerms);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">{displayText}</span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ {query && (
+ <div className="tool-section">
+ <div className="tool-label">Query:</div>
+ <pre className="tool-code">{query}</pre>
+ </div>
+ )}
+
+ {searchTerms.length > 0 && (
+ <div className="tool-section">
+ <div className="tool-label">Search Terms:</div>
+ <pre className="tool-code">{searchTerms.join(", ")}</pre>
+ </div>
+ )}
+
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Results{hasError ? " (Error)" : ""}:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <pre className={`tool-code ${hasError ? "error" : ""}`}>
+ {output || "(no output)"}
+ </pre>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default KeywordSearchTool;
@@ -0,0 +1,855 @@
+import React, { useState, useRef } from "react";
+import { Message as MessageType, LLMMessage, LLMContent, Usage } from "../types";
+import BashTool from "./BashTool";
+import PatchTool from "./PatchTool";
+import ScreenshotTool from "./ScreenshotTool";
+import GenericTool from "./GenericTool";
+import ThinkTool from "./ThinkTool";
+import KeywordSearchTool from "./KeywordSearchTool";
+import BrowserNavigateTool from "./BrowserNavigateTool";
+import BrowserEvalTool from "./BrowserEvalTool";
+import ReadImageTool from "./ReadImageTool";
+import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
+import ChangeDirTool from "./ChangeDirTool";
+import BrowserResizeTool from "./BrowserResizeTool";
+import ContextMenu from "./ContextMenu";
+import UsageDetailModal from "./UsageDetailModal";
+
+// Display data types from different tools
+interface ToolDisplay {
+ tool_use_id: string;
+ tool_name?: string;
+ display: unknown;
+}
+
+interface MessageProps {
+ message: MessageType;
+}
+
+function Message({ message }: MessageProps) {
+ // Hide system messages from the UI
+ if (message.type === "system") {
+ return null;
+ }
+
+ // Context menu state
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+ const [showUsageModal, setShowUsageModal] = useState(false);
+ const [longPressTimer, setLongPressTimer] = useState<number | null>(null);
+ const messageRef = useRef<HTMLDivElement | null>(null);
+
+ // Parse usage data if available (only for agent messages)
+ let usage: Usage | null = null;
+ if (message.type === "agent" && message.usage_data) {
+ try {
+ usage =
+ typeof message.usage_data === "string"
+ ? JSON.parse(message.usage_data)
+ : message.usage_data;
+ } catch (err) {
+ console.error("Failed to parse usage data:", err);
+ }
+ }
+
+ // Calculate duration if we have timing info
+ let durationMs: number | null = null;
+ if (usage?.start_time && usage?.end_time) {
+ const start = new Date(usage.start_time).getTime();
+ const end = new Date(usage.end_time).getTime();
+ durationMs = end - start;
+ }
+
+ // Convert Go struct Type field (number) to string type
+ // Based on llm/llm.go constants (iota continues across types in same const block):
+ // MessageRoleUser = 0, MessageRoleAssistant = 1,
+ // ContentTypeText = 2, ContentTypeThinking = 3, ContentTypeRedactedThinking = 4,
+ // ContentTypeToolUse = 5, ContentTypeToolResult = 6
+ const getContentType = (type: number): string => {
+ switch (type) {
+ case 0:
+ return "message_role_user"; // Should not occur in Content, but handle gracefully
+ case 1:
+ return "message_role_assistant"; // Should not occur in Content, but handle gracefully
+ case 2:
+ return "text";
+ case 3:
+ return "thinking";
+ case 4:
+ return "redacted_thinking";
+ case 5:
+ return "tool_use";
+ case 6:
+ return "tool_result";
+ default:
+ return "unknown";
+ }
+ };
+
+ // Get text content from message for copying
+ const getMessageText = (): string => {
+ if (!llmMessage?.Content) return "";
+
+ const textParts: string[] = [];
+ llmMessage.Content.forEach((content) => {
+ const contentType = getContentType(content.Type);
+ if (contentType === "text" && content.Text) {
+ textParts.push(content.Text);
+ }
+ });
+ return textParts.join("\n");
+ };
+
+ // Handle right-click (desktop)
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+ };
+
+ // Handle long-press (mobile)
+ const handleTouchStart = (e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ const timer = setTimeout(() => {
+ setContextMenu({ x: touch.clientX, y: touch.clientY });
+ }, 500); // 500ms long press
+ setLongPressTimer(timer);
+ };
+
+ const handleTouchEnd = () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ setLongPressTimer(null);
+ }
+ };
+
+ const handleTouchMove = () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ setLongPressTimer(null);
+ }
+ };
+
+ // Copy icon SVG
+ const CopyIcon = () => (
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+ </svg>
+ );
+
+ // Info icon SVG
+ const InfoIcon = () => (
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="16" x2="12" y2="12"></line>
+ <line x1="12" y1="8" x2="12.01" y2="8"></line>
+ </svg>
+ );
+
+ // Handle copy action
+ const handleCopy = () => {
+ const text = getMessageText();
+ if (text) {
+ navigator.clipboard.writeText(text).catch((err) => {
+ console.error("Failed to copy text:", err);
+ });
+ }
+ };
+
+ let displayData: ToolDisplay[] | null = null;
+ if (message.display_data) {
+ try {
+ displayData =
+ typeof message.display_data === "string"
+ ? JSON.parse(message.display_data)
+ : message.display_data;
+ } catch (err) {
+ console.error("Failed to parse display data:", err);
+ }
+ }
+
+ // Parse LLM data if available
+ let llmMessage: LLMMessage | null = null;
+ if (message.llm_data) {
+ try {
+ llmMessage =
+ typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+ } catch (err) {
+ console.error("Failed to parse LLM data:", err);
+ }
+ }
+
+ const isUser = message.type === "user" && !hasToolResult(llmMessage);
+ const isTool = message.type === "tool" || hasToolContent(llmMessage);
+ const isError = message.type === "error";
+
+ // Build context menu items after llmMessage is available
+ const contextMenuItems = [];
+
+ // Always show copy for messages with text content
+ const messageText = getMessageText();
+ if (messageText) {
+ contextMenuItems.push({
+ label: "Copy",
+ icon: <CopyIcon />,
+ onClick: handleCopy,
+ });
+ }
+
+ // Show usage detail only for agent messages with usage data
+ if (message.type === "agent" && usage) {
+ contextMenuItems.push({
+ label: "Usage Detail",
+ icon: <InfoIcon />,
+ onClick: () => setShowUsageModal(true),
+ });
+ }
+
+ // Build a map of tool use IDs to their inputs for linking tool_result back to tool_use
+ const toolUseMap: Record<string, { name: string; input: unknown }> = {};
+ if (llmMessage && llmMessage.Content) {
+ llmMessage.Content.forEach((content) => {
+ if (content.Type === 5 && content.ID && content.ToolName) {
+ // tool_use
+ toolUseMap[content.ID] = {
+ name: content.ToolName,
+ input: content.ToolInput,
+ };
+ }
+ });
+ }
+
+ const renderContent = (content: LLMContent) => {
+ const contentType = getContentType(content.Type);
+
+ switch (contentType) {
+ case "message_role_user":
+ case "message_role_assistant":
+ // These shouldn't occur in Content objects, but display as text if they do
+ return (
+ <div
+ style={{
+ background: "#fff7ed",
+ border: "1px solid #fed7aa",
+ borderRadius: "0.25rem",
+ padding: "0.5rem",
+ fontSize: "0.875rem",
+ }}
+ >
+ <div style={{ color: "#9a3412", fontFamily: "monospace" }}>
+ [Unexpected message role content: {contentType}]
+ </div>
+ <div style={{ marginTop: "0.25rem" }}>{content.Text || JSON.stringify(content)}</div>
+ </div>
+ );
+ case "text":
+ return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
+ case "tool_use":
+ // IMPORTANT: When adding a new tool component here, also add it to:
+ // 1. The tool_result case below
+ // 2. TOOL_COMPONENTS map in ChatInterface.tsx
+ // See AGENT.md in this directory.
+
+ // Use specialized component for bash tool
+ if (content.ToolName === "bash") {
+ return <BashTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for patch tool
+ if (content.ToolName === "patch") {
+ return <PatchTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for screenshot tool
+ if (content.ToolName === "screenshot" || content.ToolName === "browser_take_screenshot") {
+ return <ScreenshotTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for think tool
+ if (content.ToolName === "think") {
+ return <ThinkTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for change_dir tool
+ if (content.ToolName === "change_dir") {
+ return <ChangeDirTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for keyword search tool
+ if (content.ToolName === "keyword_search") {
+ return <KeywordSearchTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for browser navigate tool
+ if (content.ToolName === "browser_navigate") {
+ return <BrowserNavigateTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for browser eval tool
+ if (content.ToolName === "browser_eval") {
+ return <BrowserEvalTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for read image tool
+ if (content.ToolName === "read_image") {
+ return <ReadImageTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for browser resize tool
+ if (content.ToolName === "browser_resize") {
+ return <BrowserResizeTool toolInput={content.ToolInput} isRunning={true} />;
+ }
+ // Use specialized component for browser console logs tools
+ if (
+ content.ToolName === "browser_recent_console_logs" ||
+ content.ToolName === "browser_clear_console_logs"
+ ) {
+ return (
+ <BrowserConsoleLogsTool
+ toolName={content.ToolName}
+ toolInput={content.ToolInput}
+ isRunning={true}
+ />
+ );
+ }
+ // Default rendering for other tools using GenericTool
+ return (
+ <GenericTool
+ toolName={content.ToolName || "Unknown Tool"}
+ toolInput={content.ToolInput}
+ isRunning={true}
+ />
+ );
+ case "tool_result": {
+ const hasError = content.ToolError;
+ const toolUseId = content.ToolUseID;
+ const startTime = content.ToolUseStartTime;
+ const endTime = content.ToolUseEndTime;
+
+ // Calculate execution time if available
+ let executionTime = "";
+ if (startTime && endTime) {
+ const start = new Date(startTime).getTime();
+ const end = new Date(endTime).getTime();
+ const diffMs = end - start;
+ if (diffMs < 1000) {
+ executionTime = `${diffMs}ms`;
+ } else {
+ executionTime = `${(diffMs / 1000).toFixed(1)}s`;
+ }
+ }
+
+ // Get a short summary of the tool result for mobile-friendly display
+ const getToolResultSummary = (results: LLMContent[]) => {
+ if (!results || results.length === 0) return "No output";
+
+ const firstResult = results[0];
+ if (firstResult.Type === 2 && firstResult.Text) {
+ // text content
+ const text = firstResult.Text.trim();
+ if (text.length <= 50) return text;
+ return text.substring(0, 47) + "...";
+ }
+
+ return `${results.length} result${results.length > 1 ? "s" : ""}`;
+ };
+
+ // unused for now
+ void getToolResultSummary;
+
+ // Get tool information from the toolUseMap or fallback to content
+ const toolInfo = toolUseId && toolUseMap && toolUseMap[toolUseId];
+ const toolName =
+ (toolInfo && typeof toolInfo === "object" && toolInfo.name) ||
+ content.ToolName ||
+ "Unknown Tool";
+ const toolInput = toolInfo && typeof toolInfo === "object" ? toolInfo.input : undefined;
+
+ // Use specialized component for bash tool
+ if (toolName === "bash") {
+ return (
+ <BashTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for patch tool
+ if (toolName === "patch") {
+ return (
+ <PatchTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ display={content.Display}
+ />
+ );
+ }
+
+ // Use specialized component for screenshot tool
+ if (toolName === "screenshot" || toolName === "browser_take_screenshot") {
+ return (
+ <ScreenshotTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ display={content.Display}
+ />
+ );
+ }
+
+ // Use specialized component for think tool
+ if (toolName === "think") {
+ return (
+ <ThinkTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for change_dir tool
+ if (toolName === "change_dir") {
+ return (
+ <ChangeDirTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for keyword search tool
+ if (toolName === "keyword_search") {
+ return (
+ <KeywordSearchTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for browser navigate tool
+ if (toolName === "browser_navigate") {
+ return (
+ <BrowserNavigateTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for browser eval tool
+ if (toolName === "browser_eval") {
+ return (
+ <BrowserEvalTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for read image tool
+ if (toolName === "read_image") {
+ return (
+ <ReadImageTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ display={content.Display}
+ />
+ );
+ }
+
+ // Use specialized component for browser resize tool
+ if (toolName === "browser_resize") {
+ return (
+ <BrowserResizeTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Use specialized component for browser console logs tools
+ if (
+ toolName === "browser_recent_console_logs" ||
+ toolName === "browser_clear_console_logs"
+ ) {
+ return (
+ <BrowserConsoleLogsTool
+ toolName={toolName}
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+
+ // Default rendering for other tools using GenericTool
+ return (
+ <GenericTool
+ toolName={toolName}
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ />
+ );
+ }
+ case "redacted_thinking":
+ return <div className="text-tertiary italic text-sm">[Thinking content hidden]</div>;
+ case "thinking":
+ // Hide thinking content by default in main flow, but could be made expandable
+ return null;
+ default: {
+ // For unknown content types, show the type and try to display useful content
+ const displayText = content.Text || content.Data || "";
+ const hasMediaType = content.MediaType;
+ const hasOtherData = Object.keys(content).some(
+ (key) => key !== "Type" && key !== "ID" && content[key as keyof typeof content],
+ );
+
+ return (
+ <div
+ style={{
+ background: "var(--bg-tertiary)",
+ border: "1px solid var(--border)",
+ borderRadius: "0.25rem",
+ padding: "0.75rem",
+ }}
+ >
+ <div
+ className="text-xs text-secondary"
+ style={{ marginBottom: "0.5rem", fontFamily: "monospace" }}
+ >
+ Unknown content type: {contentType} (value: {content.Type})
+ </div>
+
+ {/* Show media content if available */}
+ {hasMediaType && (
+ <div style={{ marginBottom: "0.5rem" }}>
+ <div className="text-xs text-secondary" style={{ marginBottom: "0.25rem" }}>
+ Media Type: {content.MediaType}
+ </div>
+ {content.MediaType?.startsWith("image/") && content.Data && (
+ <img
+ src={`data:${content.MediaType};base64,${content.Data}`}
+ alt="Tool output image"
+ className="rounded border"
+ style={{ maxWidth: "100%", height: "auto", maxHeight: "300px" }}
+ />
+ )}
+ </div>
+ )}
+
+ {/* Show text content if available */}
+ {displayText && (
+ <div className="text-sm whitespace-pre-wrap break-words">{displayText}</div>
+ )}
+
+ {/* Show raw JSON for debugging if no text content */}
+ {!displayText && hasOtherData && (
+ <details className="text-xs">
+ <summary className="text-secondary" style={{ cursor: "pointer" }}>
+ Show raw content
+ </summary>
+ <pre
+ style={{
+ marginTop: "0.5rem",
+ padding: "0.5rem",
+ background: "var(--bg-base)",
+ borderRadius: "0.25rem",
+ fontSize: "0.75rem",
+ overflow: "auto",
+ }}
+ >
+ {JSON.stringify(content, null, 2)}
+ </pre>
+ </details>
+ )}
+ </div>
+ );
+ }
+ }
+ };
+
+ // Render display data for tool-specific rendering
+ const renderDisplayData = (toolDisplay: ToolDisplay, toolName?: string) => {
+ const display = toolDisplay.display;
+
+ // Skip rendering screenshot displays here - they are handled by tool_result rendering
+ if (
+ display &&
+ typeof display === "object" &&
+ "type" in display &&
+ display.type === "screenshot"
+ ) {
+ return null;
+ }
+
+ // Infer tool type from display content if tool name not provided
+ const inferredToolName =
+ toolName ||
+ (typeof display === "string" && display.includes("---") && display.includes("+++")
+ ? "patch"
+ : undefined);
+
+ // Render patch tool displays using PatchTool component
+ if (inferredToolName === "patch" && typeof display === "string") {
+ // Create a mock toolResult with the diff in Text field
+ const mockToolResult: LLMContent[] = [
+ {
+ ID: toolDisplay.tool_use_id,
+ Type: 6, // tool_result
+ Text: display,
+ },
+ ];
+
+ return (
+ <PatchTool toolInput={{}} isRunning={false} toolResult={mockToolResult} hasError={false} />
+ );
+ }
+
+ // For other types of display data, use GenericTool component
+ const mockToolResult: LLMContent[] = [
+ {
+ ID: toolDisplay.tool_use_id,
+ Type: 6, // tool_result
+ Text: JSON.stringify(display, null, 2),
+ },
+ ];
+
+ return (
+ <GenericTool
+ toolName={inferredToolName || toolName || "Tool output"}
+ toolInput={{}}
+ isRunning={false}
+ toolResult={mockToolResult}
+ hasError={false}
+ />
+ );
+ };
+
+ const getMessageClasses = () => {
+ if (isUser) {
+ return "message message-user";
+ }
+ if (isError) {
+ return "message message-error";
+ }
+ if (isTool) {
+ return "message message-tool";
+ }
+ return "message message-agent";
+ };
+
+ // Special rendering for error messages
+ if (isError) {
+ let errorText = "An error occurred";
+ if (llmMessage && llmMessage.Content && llmMessage.Content.length > 0) {
+ const textContent = llmMessage.Content.find((c) => c.Type === 2);
+ if (textContent && textContent.Text) {
+ errorText = textContent.Text;
+ }
+ }
+ return (
+ <>
+ <div
+ ref={messageRef}
+ className={getMessageClasses()}
+ onContextMenu={handleContextMenu}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onTouchMove={handleTouchMove}
+ style={{ position: "relative" }}
+ data-testid="message"
+ role="alert"
+ aria-label="Error message"
+ >
+ <div className="message-content" data-testid="message-content">
+ <div className="whitespace-pre-wrap break-words">{errorText}</div>
+ </div>
+ </div>
+ {contextMenu && contextMenuItems.length > 0 && (
+ <ContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ onClose={() => setContextMenu(null)}
+ items={contextMenuItems}
+ />
+ )}
+ {showUsageModal && usage && (
+ <UsageDetailModal
+ usage={usage}
+ durationMs={durationMs}
+ onClose={() => setShowUsageModal(false)}
+ />
+ )}
+ </>
+ );
+ }
+
+ // If we have display_data, use that for rendering (more compact, tool-specific)
+ if (displayData && displayData.length > 0) {
+ return (
+ <>
+ <div
+ ref={messageRef}
+ className={getMessageClasses()}
+ onContextMenu={handleContextMenu}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onTouchMove={handleTouchMove}
+ style={{ position: "relative" }}
+ data-testid="message"
+ role="article"
+ >
+ <div className="message-content" data-testid="message-content">
+ {displayData.map((toolDisplay, index) => (
+ <div key={index}>{renderDisplayData(toolDisplay, toolDisplay.tool_name)}</div>
+ ))}
+ </div>
+ </div>
+ {contextMenu && contextMenuItems.length > 0 && (
+ <ContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ onClose={() => setContextMenu(null)}
+ items={contextMenuItems}
+ />
+ )}
+ {showUsageModal && usage && (
+ <UsageDetailModal
+ usage={usage}
+ durationMs={durationMs}
+ onClose={() => setShowUsageModal(false)}
+ />
+ )}
+ </>
+ );
+ }
+
+ // Don't render messages with no meaningful content
+ if (!llmMessage || !llmMessage.Content || llmMessage.Content.length === 0) {
+ return null;
+ }
+
+ // Filter out thinking content, empty content, tool_use, and tool_result
+ const meaningfulContent =
+ llmMessage?.Content?.filter((c) => {
+ const contentType = c.Type;
+ // Filter out thinking (3), redacted thinking (4), tool_use (5), tool_result (6), and empty text content
+ return (
+ contentType !== 3 &&
+ contentType !== 4 &&
+ contentType !== 5 &&
+ contentType !== 6 &&
+ (c.Text?.trim() || contentType !== 2)
+ ); // 3 = thinking, 4 = redacted_thinking, 5 = tool_use, 6 = tool_result, 2 = text
+ }) || [];
+
+ // Don't filter out messages that contain operation status like "[Operation cancelled]"
+ const hasOperationStatus = llmMessage?.Content?.some(
+ (c) => c.Type === 2 && c.Text?.includes("[Operation"),
+ );
+
+ if (meaningfulContent.length === 0 && !hasOperationStatus) {
+ return null;
+ }
+
+ // If we have operation status but no meaningful content, render the status
+ const contentToRender =
+ meaningfulContent.length > 0
+ ? meaningfulContent
+ : llmMessage?.Content?.filter((c) => c.Type === 2 && c.Text?.includes("[Operation")) || [];
+
+ return (
+ <>
+ <div
+ ref={messageRef}
+ className={getMessageClasses()}
+ onContextMenu={handleContextMenu}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onTouchMove={handleTouchMove}
+ style={{ position: "relative" }}
+ data-testid="message"
+ role="article"
+ >
+ {/* Message content */}
+ <div className="message-content" data-testid="message-content">
+ {contentToRender.map((content, index) => (
+ <div key={index}>{renderContent(content)}</div>
+ ))}
+ </div>
+ </div>
+ {contextMenu && contextMenuItems.length > 0 && (
+ <ContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ onClose={() => setContextMenu(null)}
+ items={contextMenuItems}
+ />
+ )}
+ {showUsageModal && usage && (
+ <UsageDetailModal
+ usage={usage}
+ durationMs={durationMs}
+ onClose={() => setShowUsageModal(false)}
+ />
+ )}
+ </>
+ );
+}
+
+// Helper functions
+function hasToolResult(llmMessage: LLMMessage | null): boolean {
+ if (!llmMessage) return false;
+ return llmMessage.Content?.some((c) => c.Type === 6) ?? false; // 6 = tool_result
+}
+
+function hasToolContent(llmMessage: LLMMessage | null): boolean {
+ if (!llmMessage) return false;
+ return llmMessage.Content?.some((c) => c.Type === 5 || c.Type === 6) ?? false; // 5 = tool_use, 6 = tool_result
+}
+
+export default Message;
@@ -0,0 +1,428 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+
+// Web Speech API types
+interface SpeechRecognitionEvent extends Event {
+ results: SpeechRecognitionResultList;
+ resultIndex: number;
+}
+
+interface SpeechRecognitionResultList {
+ length: number;
+ item(index: number): SpeechRecognitionResult;
+ [index: number]: SpeechRecognitionResult;
+}
+
+interface SpeechRecognitionResult {
+ isFinal: boolean;
+ length: number;
+ item(index: number): SpeechRecognitionAlternative;
+ [index: number]: SpeechRecognitionAlternative;
+}
+
+interface SpeechRecognitionAlternative {
+ transcript: string;
+ confidence: number;
+}
+
+interface SpeechRecognition extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
+ onerror: ((event: Event & { error: string }) => void) | null;
+ onend: (() => void) | null;
+ start(): void;
+ stop(): void;
+ abort(): void;
+}
+
+declare global {
+ interface Window {
+ SpeechRecognition: new () => SpeechRecognition;
+ webkitSpeechRecognition: new () => SpeechRecognition;
+ }
+}
+
+interface MessageInputProps {
+ onSend: (message: string) => Promise<void>;
+ disabled?: boolean;
+ autoFocus?: boolean;
+ onFocus?: () => void;
+ injectedText?: string;
+ onClearInjectedText?: () => void;
+ /** If set, persist draft message to localStorage under this key */
+ persistKey?: string;
+}
+
+const PERSIST_KEY_PREFIX = "shelley_draft_";
+
+function MessageInput({
+ onSend,
+ disabled = false,
+ autoFocus = false,
+ onFocus,
+ injectedText,
+ onClearInjectedText,
+ persistKey,
+}: MessageInputProps) {
+ const [message, setMessage] = useState(() => {
+ // Load persisted draft if persistKey is set
+ if (persistKey) {
+ return localStorage.getItem(PERSIST_KEY_PREFIX + persistKey) || "";
+ }
+ return "";
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const [uploadsInProgress, setUploadsInProgress] = useState(0);
+ const [dragCounter, setDragCounter] = useState(0);
+ const [isListening, setIsListening] = useState(false);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const recognitionRef = useRef<SpeechRecognition | null>(null);
+ // Track the base text (before speech recognition started) and finalized speech text
+ const baseTextRef = useRef<string>("");
+ const finalizedTextRef = useRef<string>("");
+
+ // Check if speech recognition is available
+ const speechRecognitionAvailable =
+ typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
+
+ const stopListening = useCallback(() => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ recognitionRef.current = null;
+ }
+ setIsListening(false);
+ }, []);
+
+ const startListening = useCallback(() => {
+ if (!speechRecognitionAvailable) return;
+
+ const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition;
+ const recognition = new SpeechRecognitionClass();
+
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = navigator.language || "en-US";
+
+ // Capture current message as base text
+ setMessage((current) => {
+ baseTextRef.current = current;
+ finalizedTextRef.current = "";
+ return current;
+ });
+
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
+ let finalTranscript = "";
+ let interimTranscript = "";
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const transcript = event.results[i][0].transcript;
+ if (event.results[i].isFinal) {
+ finalTranscript += transcript;
+ } else {
+ interimTranscript += transcript;
+ }
+ }
+
+ // Accumulate finalized text
+ if (finalTranscript) {
+ finalizedTextRef.current += finalTranscript;
+ }
+
+ // Build the full message: base + finalized + interim
+ const base = baseTextRef.current;
+ const needsSpace = base.length > 0 && !/\s$/.test(base);
+ const spacer = needsSpace ? " " : "";
+ const fullText = base + spacer + finalizedTextRef.current + interimTranscript;
+
+ setMessage(fullText);
+ };
+
+ recognition.onerror = (event) => {
+ console.error("Speech recognition error:", event.error);
+ stopListening();
+ };
+
+ recognition.onend = () => {
+ setIsListening(false);
+ recognitionRef.current = null;
+ };
+
+ recognitionRef.current = recognition;
+ recognition.start();
+ setIsListening(true);
+ }, [speechRecognitionAvailable, stopListening]);
+
+ const toggleListening = useCallback(() => {
+ if (isListening) {
+ stopListening();
+ } else {
+ startListening();
+ }
+ }, [isListening, startListening, stopListening]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (recognitionRef.current) {
+ recognitionRef.current.abort();
+ }
+ };
+ }, []);
+
+ const uploadFile = async (file: File, insertPosition: number) => {
+ const textBefore = message.substring(0, insertPosition);
+ const textAfter = message.substring(insertPosition);
+
+ // Add a loading indicator
+ const loadingText = `[uploading ${file.name}...]`;
+ setMessage(`${textBefore}${loadingText}${textAfter}`);
+ setUploadsInProgress((prev) => prev + 1);
+
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const response = await fetch("/api/upload", {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Upload failed: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // Replace the loading placeholder with the actual file path
+ setMessage((currentMessage) => currentMessage.replace(loadingText, `[${data.path}]`));
+ } catch (error) {
+ console.error("Failed to upload file:", error);
+ // Replace loading indicator with error message
+ const errorText = `[upload failed: ${error instanceof Error ? error.message : "unknown error"}]`;
+ setMessage((currentMessage) => currentMessage.replace(loadingText, errorText));
+ } finally {
+ setUploadsInProgress((prev) => prev - 1);
+ }
+ };
+
+ const handlePaste = async (event: React.ClipboardEvent) => {
+ // Check clipboard items (works on both desktop and mobile)
+ // Mobile browsers often don't populate clipboardData.files, but items works
+ const items = event.clipboardData?.items;
+ if (items) {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.kind === "file") {
+ const file = item.getAsFile();
+ if (file) {
+ event.preventDefault();
+ const cursorPos = textareaRef.current?.selectionStart ?? message.length;
+ await uploadFile(file, cursorPos);
+ return;
+ }
+ }
+ }
+ }
+ };
+
+ const handleDragOver = (event: React.DragEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleDragEnter = (event: React.DragEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragCounter((prev) => prev + 1);
+ };
+
+ const handleDragLeave = (event: React.DragEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragCounter((prev) => prev - 1);
+ };
+
+ const handleDrop = async (event: React.DragEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragCounter(0);
+
+ if (event.dataTransfer && event.dataTransfer.files.length > 0) {
+ // Process all dropped files
+ for (let i = 0; i < event.dataTransfer.files.length; i++) {
+ const file = event.dataTransfer.files[i];
+ const insertPosition =
+ i === 0 ? (textareaRef.current?.selectionStart ?? message.length) : message.length;
+ await uploadFile(file, insertPosition);
+ // Add a space between files
+ if (i < event.dataTransfer.files.length - 1) {
+ setMessage((prev) => prev + " ");
+ }
+ }
+ }
+ };
+
+ // Auto-insert injected text (diff comments) directly into the textarea
+ useEffect(() => {
+ if (injectedText) {
+ setMessage((prev) => {
+ const needsNewline = prev.length > 0 && !prev.endsWith("\n");
+ return prev + (needsNewline ? "\n\n" : "") + injectedText;
+ });
+ onClearInjectedText?.();
+ // Focus the textarea after inserting
+ setTimeout(() => textareaRef.current?.focus(), 0);
+ }
+ }, [injectedText, onClearInjectedText]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (message.trim() && !disabled && !submitting && uploadsInProgress === 0) {
+ // Stop listening if we were recording
+ if (isListening) {
+ stopListening();
+ }
+
+ const messageToSend = message;
+ setSubmitting(true);
+ try {
+ await onSend(messageToSend);
+ // Only clear on success
+ setMessage("");
+ // Clear persisted draft on successful send
+ if (persistKey) {
+ localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
+ }
+ } catch {
+ // Keep the message on error so user can retry
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ };
+
+ const adjustTextareaHeight = () => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = "auto";
+ const scrollHeight = textareaRef.current.scrollHeight;
+ const maxHeight = 200; // Maximum height in pixels
+ textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
+ }
+ };
+
+ useEffect(() => {
+ adjustTextareaHeight();
+ }, [message]);
+
+ // Persist draft to localStorage when persistKey is set
+ useEffect(() => {
+ if (persistKey) {
+ if (message) {
+ localStorage.setItem(PERSIST_KEY_PREFIX + persistKey, message);
+ } else {
+ localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
+ }
+ }
+ }, [message, persistKey]);
+
+ useEffect(() => {
+ if (autoFocus && textareaRef.current) {
+ // Use setTimeout to ensure the component is fully rendered
+ setTimeout(() => {
+ textareaRef.current?.focus();
+ }, 0);
+ }
+ }, [autoFocus]);
+
+ const isDisabled = disabled || uploadsInProgress > 0;
+ const canSubmit = message.trim() && !isDisabled && !submitting;
+
+ const isDraggingOver = dragCounter > 0;
+ // Note: injectedText is auto-inserted via useEffect, no manual UI needed
+
+ return (
+ <div
+ className={`message-input-container ${isDraggingOver ? "drag-over" : ""}`}
+ onDragOver={handleDragOver}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ {isDraggingOver && (
+ <div className="drag-overlay">
+ <div className="drag-overlay-content">Drop files here</div>
+ </div>
+ )}
+ <form onSubmit={handleSubmit} className="message-input-form">
+ <textarea
+ ref={textareaRef}
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ onFocus={() => {
+ // Scroll to bottom after keyboard animation settles
+ if (onFocus) {
+ requestAnimationFrame(() => requestAnimationFrame(onFocus));
+ }
+ }}
+ placeholder="Type your message..."
+ className="message-textarea"
+ disabled={isDisabled}
+ rows={1}
+ aria-label="Message input"
+ data-testid="message-input"
+ autoFocus={autoFocus}
+ />
+ {speechRecognitionAvailable && (
+ <button
+ type="button"
+ onClick={toggleListening}
+ disabled={isDisabled}
+ className={`message-voice-btn ${isListening ? "listening" : ""}`}
+ aria-label={isListening ? "Stop voice input" : "Start voice input"}
+ data-testid="voice-button"
+ >
+ {isListening ? (
+ <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+ <circle cx="12" cy="12" r="6" />
+ </svg>
+ ) : (
+ <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+ <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
+ </svg>
+ )}
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={!canSubmit}
+ className="message-send-btn"
+ aria-label="Send message"
+ data-testid="send-button"
+ >
+ {isDisabled || submitting ? (
+ <div className="flex items-center justify-center">
+ <div className="spinner spinner-small" style={{ borderTopColor: "white" }}></div>
+ </div>
+ ) : (
+ <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+ <path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
+ </svg>
+ )}
+ </button>
+ </form>
+ </div>
+ );
+}
+
+export default MessageInput;
@@ -0,0 +1,44 @@
+import React from "react";
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+}
+
+function Modal({ isOpen, onClose, title, children }: ModalProps) {
+ if (!isOpen) return null;
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ onClose();
+ }
+ };
+
+ return (
+ <div className="modal-overlay" onClick={handleBackdropClick}>
+ <div className="modal">
+ {/* Header */}
+ <div className="modal-header">
+ <h2 className="modal-title">{title}</h2>
+ <button onClick={onClose} className="btn-icon" aria-label="Close modal">
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="modal-body">{children}</div>
+ </div>
+ </div>
+ );
+}
+
+export default Modal;
@@ -0,0 +1,165 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface PatchToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown;
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+ display?: unknown; // Display data from the tool_result Content (contains the diff)
+}
+
+function PatchTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ display,
+}: PatchToolProps) {
+ // Default to collapsed for errors (since agents typically recover), expanded otherwise
+ const [isExpanded, setIsExpanded] = useState(!hasError);
+
+ // Extract path from toolInput
+ const path =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "path" in toolInput &&
+ typeof toolInput.path === "string"
+ ? toolInput.path
+ : typeof toolInput === "string"
+ ? toolInput
+ : "";
+
+ // Extract diff from display (preferred) or fall back to toolResult
+ const diff =
+ typeof display === "string"
+ ? display
+ : toolResult && toolResult.length > 0 && toolResult[0].Text
+ ? toolResult[0].Text
+ : "";
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ // Parse unified diff to extract filename and colorize lines
+ const parseDiff = (diffText: string) => {
+ if (!diffText) return { filename: path, lines: [] };
+
+ const lines = diffText.split("\n");
+ let filename = path;
+
+ // Extract filename from diff header if present
+ for (const line of lines) {
+ if (line.startsWith("---")) {
+ // Format: --- a/path/to/file.txt
+ const match = line.match(/^---\s+(.+?)\s*$/);
+ if (match) {
+ filename = match[1].replace(/^[ab]\//, ""); // Remove a/ or b/ prefix
+ }
+ }
+ }
+
+ return { filename, lines };
+ };
+
+ const { filename, lines } = parseDiff(diff);
+
+ return (
+ <div
+ className="patch-tool"
+ data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+ >
+ <div className="patch-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="patch-tool-summary">
+ <span className={`patch-tool-emoji ${isRunning ? "running" : ""}`}>ποΈ</span>
+ <span className="patch-tool-filename">{filename || "patch"}</span>
+ {isComplete && hasError && <span className="patch-tool-error">β</span>}
+ {isComplete && !hasError && <span className="patch-tool-success">β</span>}
+ </div>
+ <button
+ className="patch-tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="patch-tool-details">
+ {isComplete && !hasError && diff && (
+ <div className="patch-tool-section">
+ {executionTime && (
+ <div className="patch-tool-label">
+ <span>Diff:</span>
+ <span className="patch-tool-time">{executionTime}</span>
+ </div>
+ )}
+ <pre className="patch-tool-diff">
+ {lines.map((line, idx) => {
+ // Determine line type for styling
+ let className = "patch-diff-line";
+ if (line.startsWith("+") && !line.startsWith("+++")) {
+ className += " patch-diff-addition";
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
+ className += " patch-diff-deletion";
+ } else if (line.startsWith("@@")) {
+ className += " patch-diff-hunk";
+ } else if (line.startsWith("---") || line.startsWith("+++")) {
+ className += " patch-diff-header";
+ }
+
+ return (
+ <div key={idx} className={className}>
+ {line || " "}
+ </div>
+ );
+ })}
+ </pre>
+ </div>
+ )}
+
+ {isComplete && hasError && (
+ <div className="patch-tool-section">
+ <div className="patch-tool-label">
+ <span>Error:</span>
+ {executionTime && <span className="patch-tool-time">{executionTime}</span>}
+ </div>
+ <pre className="patch-tool-error-message">{diff || "Patch failed"}</pre>
+ </div>
+ )}
+
+ {isRunning && (
+ <div className="patch-tool-section">
+ <div className="patch-tool-label">Applying patch...</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default PatchTool;
@@ -0,0 +1,158 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ReadImageToolProps {
+ toolInput?: unknown; // { path: string }
+ isRunning?: boolean;
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+ display?: unknown; // Display data from the tool_result Content
+}
+
+function ReadImageTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ display,
+}: ReadImageToolProps) {
+ const [isExpanded, setIsExpanded] = useState(true); // Default to expanded
+
+ // Extract display info from toolInput
+ const getPath = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "path" in input &&
+ typeof input.path === "string"
+ ) {
+ return input.path;
+ }
+ return undefined;
+ };
+
+ const getId = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "id" in input &&
+ typeof input.id === "string"
+ ) {
+ return input.id;
+ }
+ return undefined;
+ };
+
+ const filename = getPath(toolInput) || getId(toolInput) || "image";
+
+ // Use display data passed as prop (from tool_result Content.Display)
+ const displayData = display;
+
+ // Construct image URL
+ let imageUrl: string | undefined = undefined;
+ if (displayData && typeof displayData === "object" && displayData !== null) {
+ const url =
+ "url" in displayData && typeof displayData.url === "string" ? displayData.url : undefined;
+ const path =
+ "path" in displayData && typeof displayData.path === "string" ? displayData.path : undefined;
+ const id =
+ "id" in displayData && typeof displayData.id === "string" ? displayData.id : undefined;
+
+ imageUrl =
+ url ||
+ (path
+ ? `/api/read?path=${encodeURIComponent(path)}`
+ : id
+ ? `/api/read?path=${encodeURIComponent(id)}`
+ : undefined);
+ }
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div
+ className="screenshot-tool"
+ data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+ >
+ <div className="screenshot-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="screenshot-tool-summary">
+ <span className={`screenshot-tool-emoji ${isRunning ? "running" : ""}`}>πΌοΈ</span>
+ <span className="screenshot-tool-filename">{filename}</span>
+ {isComplete && hasError && <span className="screenshot-tool-error">β</span>}
+ {isComplete && !hasError && <span className="screenshot-tool-success">β</span>}
+ </div>
+ <button
+ className="screenshot-tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="screenshot-tool-details">
+ {isComplete && !hasError && imageUrl && (
+ <div className="screenshot-tool-section">
+ {executionTime && (
+ <div className="screenshot-tool-label">
+ <span>Image:</span>
+ <span className="screenshot-tool-time">{executionTime}</span>
+ </div>
+ )}
+ <div className="screenshot-tool-image-container">
+ <a href={imageUrl} target="_blank" rel="noopener noreferrer">
+ <img
+ src={imageUrl}
+ alt={`Image: ${filename}`}
+ style={{ maxWidth: "100%", height: "auto" }}
+ />
+ </a>
+ </div>
+ </div>
+ )}
+
+ {isComplete && hasError && (
+ <div className="screenshot-tool-section">
+ <div className="screenshot-tool-label">
+ <span>Error:</span>
+ {executionTime && <span className="screenshot-tool-time">{executionTime}</span>}
+ </div>
+ <pre className="screenshot-tool-error-message">
+ {toolResult && toolResult[0]?.Text ? toolResult[0].Text : "Image read failed"}
+ </pre>
+ </div>
+ )}
+
+ {isRunning && (
+ <div className="screenshot-tool-section">
+ <div className="screenshot-tool-label">Reading image...</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default ReadImageTool;
@@ -0,0 +1,175 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ScreenshotToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown;
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+ display?: unknown; // Display data from the tool_result Content
+}
+
+function ScreenshotTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ display,
+}: ScreenshotToolProps) {
+ const [isExpanded, setIsExpanded] = useState(true); // Default to expanded
+
+ // Extract display info from toolInput
+ const getPath = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "path" in input &&
+ typeof input.path === "string"
+ ) {
+ return input.path;
+ }
+ return undefined;
+ };
+
+ const getId = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "id" in input &&
+ typeof input.id === "string"
+ ) {
+ return input.id;
+ }
+ return undefined;
+ };
+
+ const getSelector = (input: unknown): string | undefined => {
+ if (
+ typeof input === "object" &&
+ input !== null &&
+ "selector" in input &&
+ typeof input.selector === "string"
+ ) {
+ return input.selector;
+ }
+ return undefined;
+ };
+
+ const filename = getPath(toolInput) || getId(toolInput) || getSelector(toolInput) || "screenshot";
+
+ // Use display data passed as prop (from tool_result Content.Display)
+ const displayData = display;
+
+ // Construct image URL
+ let imageUrl: string | undefined = undefined;
+ if (displayData && typeof displayData === "object" && displayData !== null) {
+ const url =
+ "url" in displayData && typeof displayData.url === "string" ? displayData.url : undefined;
+ const path =
+ "path" in displayData && typeof displayData.path === "string" ? displayData.path : undefined;
+ const id =
+ "id" in displayData && typeof displayData.id === "string" ? displayData.id : undefined;
+
+ imageUrl =
+ url ||
+ (path
+ ? `/api/read?path=${encodeURIComponent(path)}`
+ : id
+ ? `/api/read?path=${encodeURIComponent(id)}`
+ : undefined);
+ }
+
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div
+ className="screenshot-tool"
+ data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+ >
+ <div className="screenshot-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="screenshot-tool-summary">
+ <span className={`screenshot-tool-emoji ${isRunning ? "running" : ""}`}>π·</span>
+ <span className="screenshot-tool-filename">{filename}</span>
+ {isComplete && hasError && <span className="screenshot-tool-error">β</span>}
+ {isComplete && !hasError && <span className="screenshot-tool-success">β</span>}
+ </div>
+ <button
+ className="screenshot-tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="screenshot-tool-details">
+ {isComplete && !hasError && imageUrl && (
+ <div className="screenshot-tool-section">
+ {executionTime && (
+ <div className="screenshot-tool-label">
+ <span>Screenshot:</span>
+ <span className="screenshot-tool-time">{executionTime}</span>
+ </div>
+ )}
+ <div className="screenshot-tool-image-container">
+ <a href={imageUrl} target="_blank" rel="noopener noreferrer">
+ <img
+ src={imageUrl}
+ alt={`Screenshot: ${filename}`}
+ style={{ maxWidth: "100%", height: "auto" }}
+ />
+ </a>
+ </div>
+ </div>
+ )}
+
+ {isComplete && hasError && (
+ <div className="screenshot-tool-section">
+ <div className="screenshot-tool-label">
+ <span>Error:</span>
+ {executionTime && <span className="screenshot-tool-time">{executionTime}</span>}
+ </div>
+ <pre className="screenshot-tool-error-message">
+ {toolResult && toolResult[0]?.Text
+ ? toolResult[0].Text
+ : "Screenshot capture failed"}
+ </pre>
+ </div>
+ )}
+
+ {isRunning && (
+ <div className="screenshot-tool-section">
+ <div className="screenshot-tool-label">Capturing screenshot...</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default ScreenshotTool;
@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ThinkToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { thoughts: string }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+}
+
+function ThinkTool({ toolInput, isRunning, toolResult, hasError, executionTime }: ThinkToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract thoughts from toolInput
+ const thoughts =
+ typeof toolInput === "object" &&
+ toolInput !== null &&
+ "thoughts" in toolInput &&
+ typeof toolInput.thoughts === "string"
+ ? toolInput.thoughts
+ : typeof toolInput === "string"
+ ? toolInput
+ : "";
+
+ // Truncate thoughts for display - get first 50 chars
+ const truncateThoughts = (text: string, maxLen: number = 50) => {
+ if (!text) return "";
+ if (text.length <= maxLen) return text;
+ return text.substring(0, maxLen) + "...";
+ };
+
+ const displayThoughts = truncateThoughts(thoughts);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>π</span>
+ <span className="tool-command">
+ {displayThoughts || (isRunning ? "thinking..." : "thinking...")}
+ </span>
+ {isComplete && hasError && <span className="tool-error">β</span>}
+ {isComplete && !hasError && <span className="tool-success">β</span>}
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">
+ Thoughts:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <div className={`tool-code ${hasError ? "error" : ""}`}>
+ {thoughts || "(no thoughts)"}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default ThinkTool;
@@ -0,0 +1,166 @@
+import React from "react";
+import { Usage } from "../types";
+
+interface UsageDetailModalProps {
+ usage: Usage;
+ durationMs: number | null;
+ onClose: () => void;
+}
+
+function UsageDetailModal({ usage, durationMs, onClose }: UsageDetailModalProps) {
+ // Format duration in human-readable format
+ const formatDuration = (ms: number): string => {
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
+ return `${(ms / 60000).toFixed(2)}m`;
+ };
+
+ // Format timestamp for display
+ const formatTimestamp = (isoString: string): string => {
+ const date = new Date(isoString);
+ return date.toLocaleString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ };
+
+ // Close on escape key
+ React.useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [onClose]);
+
+ return (
+ <div
+ style={{
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 10001,
+ padding: "16px",
+ }}
+ onClick={onClose}
+ >
+ <div
+ style={{
+ backgroundColor: "#ffffff",
+ borderRadius: "8px",
+ padding: "24px",
+ maxWidth: "500px",
+ width: "100%",
+ boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
+ }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "20px",
+ }}
+ >
+ <h2 style={{ fontSize: "18px", fontWeight: "600", color: "#1f2937", margin: 0 }}>
+ Usage Details
+ </h2>
+ <button
+ onClick={onClose}
+ style={{
+ background: "none",
+ border: "none",
+ fontSize: "24px",
+ color: "#6b7280",
+ cursor: "pointer",
+ padding: "0",
+ width: "32px",
+ height: "32px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: "4px",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = "#f3f4f6";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = "transparent";
+ }}
+ aria-label="Close"
+ >
+ Γ
+ </button>
+ </div>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "auto 1fr",
+ gap: "12px 20px",
+ fontSize: "14px",
+ }}
+ >
+ {usage.model && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Model:</div>
+ <div style={{ color: "#1f2937" }}>{usage.model}</div>
+ </>
+ )}
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Input Tokens:</div>
+ <div style={{ color: "#1f2937" }}>{usage.input_tokens.toLocaleString()}</div>
+ {usage.cache_read_input_tokens > 0 && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Cache Read:</div>
+ <div style={{ color: "#1f2937" }}>
+ {usage.cache_read_input_tokens.toLocaleString()}
+ </div>
+ </>
+ )}
+ {usage.cache_creation_input_tokens > 0 && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Cache Write:</div>
+ <div style={{ color: "#1f2937" }}>
+ {usage.cache_creation_input_tokens.toLocaleString()}
+ </div>
+ </>
+ )}
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Output Tokens:</div>
+ <div style={{ color: "#1f2937" }}>{usage.output_tokens.toLocaleString()}</div>
+ {usage.cost_usd > 0 && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Cost:</div>
+ <div style={{ color: "#1f2937" }}>${usage.cost_usd.toFixed(4)}</div>
+ </>
+ )}
+ {durationMs !== null && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Duration:</div>
+ <div style={{ color: "#1f2937" }}>{formatDuration(durationMs)}</div>
+ </>
+ )}
+ {usage.end_time && (
+ <>
+ <div style={{ color: "#6b7280", fontWeight: "500" }}>Timestamp:</div>
+ <div style={{ color: "#1f2937" }}>{formatTimestamp(usage.end_time)}</div>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export default UsageDetailModal;
@@ -0,0 +1,46 @@
+// Auto-generated by shelley.exe.dev/cmd/go2ts.go
+// Do not edit manually - regenerate with: go run ./cmd/go2ts -o ui/src/generated-types.ts
+
+// DO NOT EDIT. This file is automatically generated.
+
+export interface Conversation {
+ conversation_id: string;
+ slug: string | null;
+ user_initiated: boolean;
+ created_at: string;
+ updated_at: string;
+ cwd: string | null;
+ archived: boolean;
+}
+
+export interface Usage {
+ input_tokens: number;
+ cache_creation_input_tokens: number;
+ cache_read_input_tokens: number;
+ output_tokens: number;
+ cost_usd: number;
+ model?: string;
+ start_time?: string | null;
+ end_time?: string | null;
+}
+
+export interface ApiMessageForTS {
+ message_id: string;
+ conversation_id: string;
+ sequence_id: number;
+ type: string;
+ llm_data?: string | null;
+ user_data?: string | null;
+ usage_data?: string | null;
+ created_at: string;
+ display_data?: string | null;
+ end_of_turn?: boolean | null;
+}
+
+export interface StreamResponseForTS {
+ messages: ApiMessageForTS[] | null;
+ conversation: Conversation;
+ agent_working: boolean;
+}
+
+export type MessageType = "user" | "agent" | "tool" | "error" | "system";
@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta
+ name="viewport"
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
+ />
+ <meta name="theme-color" content="#1f2937" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+ <meta name="apple-mobile-web-app-title" content="Shelley" />
+ <link rel="manifest" href="/manifest.json" />
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+ <title>Shelley Agent</title>
+ <link rel="stylesheet" href="/styles.css" />
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/main.js"></script>
+ </body>
+</html>
@@ -0,0 +1,10 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+// Render main app
+const rootContainer = document.getElementById("root");
+if (!rootContainer) throw new Error("Root container not found");
+
+const root = createRoot(rootContainer);
+root.render(<App />);
@@ -0,0 +1,192 @@
+import {
+ Conversation,
+ StreamResponse,
+ ChatRequest,
+ GitDiffInfo,
+ GitFileInfo,
+ GitFileDiff,
+} from "../types";
+
+class ApiService {
+ private baseUrl = "/api";
+
+ // Common headers for state-changing requests (CSRF protection)
+ private postHeaders = {
+ "Content-Type": "application/json",
+ "X-Shelley-Request": "1",
+ };
+
+ async getConversations(): Promise<Conversation[]> {
+ const response = await fetch(`${this.baseUrl}/conversations`);
+ if (!response.ok) {
+ throw new Error(`Failed to get conversations: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async sendMessageWithNewConversation(request: ChatRequest): Promise<{ conversation_id: string }> {
+ const response = await fetch(`${this.baseUrl}/conversations/new`, {
+ method: "POST",
+ headers: this.postHeaders,
+ body: JSON.stringify(request),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to send message: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async getConversation(conversationId: string): Promise<StreamResponse> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}`);
+ if (!response.ok) {
+ throw new Error(`Failed to get messages: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async sendMessage(conversationId: string, request: ChatRequest): Promise<void> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/chat`, {
+ method: "POST",
+ headers: this.postHeaders,
+ body: JSON.stringify(request),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to send message: ${response.statusText}`);
+ }
+ }
+
+ createMessageStream(conversationId: string): EventSource {
+ return new EventSource(`${this.baseUrl}/conversation/${conversationId}/stream`);
+ }
+
+ async cancelConversation(conversationId: string): Promise<void> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/cancel`, {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to cancel conversation: ${response.statusText}`);
+ }
+ }
+
+ async validateCwd(path: string): Promise<{ valid: boolean; error?: string }> {
+ const response = await fetch(`${this.baseUrl}/validate-cwd?path=${encodeURIComponent(path)}`);
+ if (!response.ok) {
+ throw new Error(`Failed to validate cwd: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async listDirectory(path?: string): Promise<{
+ path: string;
+ parent: string;
+ entries: Array<{ name: string; is_dir: boolean }>;
+ error?: string;
+ }> {
+ const url = path
+ ? `${this.baseUrl}/list-directory?path=${encodeURIComponent(path)}`
+ : `${this.baseUrl}/list-directory`;
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to list directory: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async getArchivedConversations(): Promise<Conversation[]> {
+ const response = await fetch(`${this.baseUrl}/conversations/archived`);
+ if (!response.ok) {
+ throw new Error(`Failed to get archived conversations: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async archiveConversation(conversationId: string): Promise<Conversation> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/archive`, {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to archive conversation: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async unarchiveConversation(conversationId: string): Promise<Conversation> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/unarchive`, {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to unarchive conversation: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async deleteConversation(conversationId: string): Promise<void> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/delete`, {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to delete conversation: ${response.statusText}`);
+ }
+ }
+
+ async getConversationBySlug(slug: string): Promise<Conversation | null> {
+ const response = await fetch(
+ `${this.baseUrl}/conversation-by-slug/${encodeURIComponent(slug)}`,
+ );
+ if (response.status === 404) {
+ return null;
+ }
+ if (!response.ok) {
+ throw new Error(`Failed to get conversation by slug: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ // Git diff APIs
+ async getGitDiffs(cwd: string): Promise<{ diffs: GitDiffInfo[]; gitRoot: string }> {
+ const response = await fetch(`${this.baseUrl}/git/diffs?cwd=${encodeURIComponent(cwd)}`);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || response.statusText);
+ }
+ return response.json();
+ }
+
+ async getGitDiffFiles(diffId: string, cwd: string): Promise<GitFileInfo[]> {
+ const response = await fetch(
+ `${this.baseUrl}/git/diffs/${diffId}/files?cwd=${encodeURIComponent(cwd)}`,
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to get diff files: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async getGitFileDiff(diffId: string, filePath: string, cwd: string): Promise<GitFileDiff> {
+ const response = await fetch(
+ `${this.baseUrl}/git/file-diff/${diffId}/${filePath}?cwd=${encodeURIComponent(cwd)}`,
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to get file diff: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async renameConversation(conversationId: string, slug: string): Promise<Conversation> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/rename`, {
+ method: "POST",
+ headers: this.postHeaders,
+ body: JSON.stringify({ slug }),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to rename conversation: ${response.statusText}`);
+ }
+ return response.json();
+ }
+}
+
+export const api = new ApiService();
@@ -0,0 +1,3037 @@
+/* CSS Reset and Base Styles */
+* {
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ height: 100dvh;
+ overflow: hidden;
+}
+
+body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ font-family:
+ "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ letter-spacing: -0.01em;
+}
+
+/* CSS Variables */
+:root {
+ --font-mono:
+ "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
+ --primary: #2563eb;
+ --primary-dark: #1d4ed8;
+ --bg-base: #ffffff;
+ --bg-secondary: #f9fafb;
+ --bg-tertiary: #f3f4f6;
+ --border: #e5e7eb;
+ --text-primary: #111827;
+ --text-secondary: #6b7280;
+ --text-tertiary: #9ca3af;
+ --success-bg: #f0fdf4;
+ --success-border: #bbf7d0;
+ --success-text: #166534;
+ --error-bg: #fef2f2;
+ --error-border: #fecaca;
+ --error-text: #991b1b;
+ --blue-bg: #eff6ff;
+ --blue-border: #bfdbfe;
+ --blue-text: #1e40af;
+ --green-600: #16a34a;
+ --green-700: #15803d;
+ --gray-50: #f9fafb;
+ --gray-100: #f3f4f6;
+ --gray-200: #e5e7eb;
+ --gray-300: #d1d5db;
+ --gray-400: #9ca3af;
+ --gray-500: #6b7280;
+ --gray-600: #4b5563;
+ --gray-700: #374151;
+ --gray-800: #1f2937;
+ --gray-900: #111827;
+}
+
+.dark {
+ --bg-base: #1f2937;
+ --bg-secondary: #111827;
+ --bg-tertiary: #374151;
+ --border: #374151;
+ --text-primary: #f9fafb;
+ --text-secondary: #9ca3af;
+ --text-tertiary: #6b7280;
+ --success-bg: rgba(34, 197, 94, 0.1);
+ --success-border: rgba(34, 197, 94, 0.3);
+ --success-text: #86efac;
+ --error-bg: rgba(239, 68, 68, 0.1);
+ --error-border: rgba(239, 68, 68, 0.3);
+ --error-text: #fca5a5;
+ --blue-bg: rgba(59, 130, 246, 0.1);
+ --blue-border: rgba(59, 130, 246, 0.3);
+ --blue-text: #93c5fd;
+}
+
+/* Layout */
+.app-container {
+ display: flex;
+ height: 100vh; /* Fallback for older browsers */
+ height: 100dvh; /* Dynamic viewport height - adjusts when mobile browser chrome appears/hides */
+ background: var(--bg-secondary);
+ position: relative;
+ overflow: hidden;
+}
+
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.screen-height {
+ height: 100vh; /* Fallback for older browsers */
+ height: 100dvh;
+}
+
+.full-height {
+ height: 100%;
+}
+
+/* Flexbox utilities */
+.flex {
+ display: flex;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.flex-1 {
+ flex: 1;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.space-x-2 > * + * {
+ margin-left: 0.5rem;
+}
+
+.space-x-3 > * + * {
+ margin-left: 0.75rem;
+}
+
+.space-y-2 > * + * {
+ margin-top: 0.5rem;
+}
+
+.space-y-3 > * + * {
+ margin-top: 0.75rem;
+}
+
+.space-y-4 > * + * {
+ margin-top: 1rem;
+}
+
+/* Text */
+.text-center {
+ text-align: center;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.6;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.italic {
+ font-style: italic;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.break-words {
+ word-break: break-word;
+}
+
+.break-all {
+ word-break: break-all;
+}
+
+.whitespace-pre-wrap {
+ white-space: pre-wrap;
+}
+
+/* Colors */
+.text-primary {
+ color: var(--text-primary);
+}
+
+.text-secondary {
+ color: var(--text-secondary);
+}
+
+.text-tertiary {
+ color: var(--text-tertiary);
+}
+
+.text-white {
+ color: white;
+}
+
+.text-error {
+ color: var(--error-text);
+}
+
+.text-success {
+ color: var(--success-text);
+}
+
+.text-blue {
+ color: var(--blue-text);
+}
+
+.bg-primary {
+ background: var(--primary);
+}
+
+.bg-base {
+ background: var(--bg-base);
+}
+
+.bg-secondary {
+ background: var(--bg-secondary);
+}
+
+/* Buttons */
+button {
+ font-family: inherit;
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0;
+ color: inherit;
+}
+
+.btn {
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.25rem;
+ transition: background-color 0.2s;
+}
+
+.btn:hover {
+ background: var(--bg-tertiary);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ padding: 0.5rem 1rem;
+ background: var(--primary);
+ color: white;
+ border-radius: 0.25rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+ letter-spacing: -0.01em;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--primary-dark);
+}
+
+.btn-primary:disabled {
+ background: var(--gray-300);
+ cursor: not-allowed;
+}
+
+.dark .btn-primary:disabled {
+ background: var(--gray-600);
+}
+
+.btn-secondary {
+ padding: 0.5rem 1rem;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+ letter-spacing: -0.01em;
+}
+
+.btn-secondary:hover {
+ background: var(--bg-tertiary);
+}
+
+.btn-icon {
+ padding: 0.5rem;
+ border-radius: 0.375rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+}
+
+.btn-icon:hover {
+ background: var(--bg-tertiary);
+}
+
+.btn-icon-sm {
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ background-color 0.2s,
+ opacity 0.2s;
+ opacity: 0.4;
+ color: var(--text-secondary);
+}
+
+.btn-icon-sm:hover {
+ background: var(--gray-200);
+ opacity: 1;
+}
+
+.dark .btn-icon-sm:hover {
+ background: var(--gray-600);
+}
+
+.conversation-item:hover .btn-icon-sm {
+ opacity: 0.7;
+}
+
+.conversation-item.active .btn-icon-sm {
+ color: rgba(255, 255, 255, 0.8);
+ opacity: 0.7;
+}
+
+.conversation-item.active .btn-icon-sm:hover {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ opacity: 1;
+}
+
+.btn-icon-sm.btn-danger:hover {
+ background: var(--red-600);
+ color: white;
+ opacity: 1;
+}
+
+.btn-new {
+ width: 2rem;
+ height: 2rem;
+ background: var(--green-600);
+ color: white;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+}
+
+.btn-new:hover {
+ background: var(--green-700);
+}
+
+/* Header */
+.header {
+ flex: 0 0 auto;
+ background: var(--bg-base);
+ border-bottom: 1px solid var(--border);
+ padding: 0.5rem 1rem;
+ padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
+ padding-left: max(1rem, env(safe-area-inset-left));
+ padding-right: max(1rem, env(safe-area-inset-right));
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0; /* Allow shrinking below content size */
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.header-title {
+ font-size: 1rem;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ letter-spacing: -0.02em;
+ min-width: 0; /* Allow shrinking */
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0; /* Never shrink the actions */
+}
+
+/* Drawer/Sidebar */
+.drawer {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 50;
+ width: 20rem;
+ max-width: calc(100vw - 3rem); /* Ensure drawer doesn't fill entire screen on mobile */
+ background: var(--bg-base);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ height: 100dvh;
+ transform: translateX(-100%);
+ transition: transform 0.3s ease;
+ padding-top: env(safe-area-inset-top);
+ padding-left: env(safe-area-inset-left);
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+.drawer.open {
+ transform: translateX(0);
+}
+
+.drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.drawer-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.drawer-body {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.drawer-section {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.drawer-footer {
+ padding: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Backdrop */
+.backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 40;
+}
+
+/* Conversation List */
+.conversation-list {
+ padding: 0.5rem;
+}
+
+.conversation-item {
+ width: 100%;
+ text-align: left;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ margin-bottom: 0.25rem;
+ transition: background-color 0.2s;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.conversation-item:hover {
+ background: var(--bg-tertiary);
+}
+
+.conversation-item.active {
+ background: var(--primary);
+ color: white;
+}
+
+.conversation-item .conversation-title {
+ font-weight: 500;
+ font-size: 0.875rem;
+ word-break: break-all;
+}
+
+.conversation-item .conversation-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ min-width: 0;
+}
+
+.conversation-item .conversation-date {
+ flex-shrink: 0;
+}
+
+.conversation-item.active .conversation-meta {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.conversation-item .conversation-cwd {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ opacity: 0.8;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+ min-width: 0;
+}
+
+/* Messages */
+.messages-container {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+ padding: 1rem;
+ padding-left: max(1rem, env(safe-area-inset-left));
+ padding-right: max(1rem, env(safe-area-inset-right));
+}
+
+.messages-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.message {
+ display: flex;
+ flex-direction: column;
+}
+
+.message-content {
+ padding: 1rem;
+ border-radius: 0.5rem;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ min-width: 0;
+}
+
+.message-user .message-content {
+ margin-left: auto;
+ max-width: 80%;
+ color: var(--primary);
+}
+
+.message-agent .message-content,
+.message-tool .message-content {
+ margin-right: auto;
+ max-width: 100%;
+}
+
+.thinking-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ margin: 0.25rem 0 0.75rem 0;
+ padding-left: 0.5rem;
+}
+
+.thinking-dots {
+ display: inline-flex;
+ gap: 0.2rem;
+ font-size: 1.25rem;
+ letter-spacing: 0.15rem;
+ color: var(--blue-text);
+}
+
+.thinking-dots span {
+ animation: thinking-dot 1.2s infinite ease-in-out;
+ opacity: 0.25;
+}
+
+.thinking-dots span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+
+.thinking-dots span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+@keyframes thinking-dot {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.25;
+ transform: translateY(0);
+ }
+ 40% {
+ opacity: 1;
+ transform: translateY(-0.2rem);
+ }
+}
+
+.message-error .message-content {
+ margin-right: auto;
+ max-width: 100%;
+ background: var(--error-bg);
+ border: 2px solid var(--error-border);
+ color: var(--error-text);
+}
+
+/* Tool Display */
+.tool-use {
+ background: var(--blue-bg);
+ border: 1px solid var(--blue-border);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ margin: 0.5rem 0;
+}
+
+.tool-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.tool-name {
+ font-weight: 500;
+ color: var(--blue-text);
+}
+
+.tool-input {
+ font-size: 0.875rem;
+ font-family: monospace;
+ background: var(--gray-100);
+ border-radius: 0.25rem;
+ padding: 0.5rem;
+ overflow-wrap: break-word;
+ word-break: break-all;
+ white-space: pre-wrap;
+}
+
+.dark .tool-input {
+ background: var(--gray-800);
+}
+
+.tool-result-details {
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+}
+
+.tool-result-details.error {
+ border-color: var(--error-border);
+}
+
+.tool-result-summary {
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ transition: background-color 0.2s;
+ background: var(--bg-tertiary);
+}
+
+.tool-result-summary:hover {
+ background: var(--bg-secondary);
+}
+
+.tool-result-details.error .tool-result-summary {
+ background: var(--error-bg);
+ color: var(--error-text);
+}
+
+.tool-result-content {
+ padding: 0.75rem;
+ padding-top: 0;
+}
+
+.tool-result-section {
+ background: var(--blue-bg);
+ border: 1px solid var(--blue-border);
+ border-radius: 0.25rem;
+ padding: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.tool-result-section.output {
+ background: var(--success-bg);
+ border-color: var(--success-border);
+}
+
+.tool-result-section.error.output {
+ background: var(--error-bg);
+ border-color: var(--error-border);
+}
+
+.tool-result-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+ color: var(--blue-text);
+}
+
+.tool-result-section.output .tool-result-label {
+ color: var(--success-text);
+}
+
+.tool-result-section.error.output .tool-result-label {
+ color: var(--error-text);
+}
+
+.tool-result-data {
+ font-size: 0.875rem;
+ font-family: monospace;
+ overflow-wrap: break-word;
+ word-break: break-all;
+ white-space: pre-wrap;
+}
+
+.tool-result-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.tool-result-status {
+ font-size: 0.75rem;
+}
+
+.tool-result-status.success {
+ color: var(--success-text);
+}
+
+.tool-result-status.error {
+ color: var(--error-text);
+}
+
+.tool-result-time {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+}
+
+/* Tool running state */
+.tool-running {
+ background: var(--blue-bg);
+ border: 1px solid var(--blue-border);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ margin: 0.5rem 0;
+}
+
+.tool-running-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.tool-status-running {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-style: italic;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Tool Component (shared styles for all tools) */
+.tool {
+ background: var(--gray-100);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+ width: 100%;
+}
+
+.dark .tool {
+ background: var(--gray-800);
+}
+
+.tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.tool-header:hover {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 0.5rem;
+}
+
+.dark .tool-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.tool-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.tool-emoji {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.tool-emoji.running {
+ animation: rotate 1s linear infinite;
+}
+
+.tool-command {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tool-running {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-style: italic;
+ flex-shrink: 0;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.tool-success {
+ color: var(--success-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.tool-error {
+ color: var(--error-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.tool-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.tool-toggle:hover {
+ color: var(--text-primary);
+}
+
+.tool-details {
+ padding: 0 1rem 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.tool-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.tool-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.tool-time {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ font-weight: normal;
+}
+
+.tool-code {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--bg-base);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-primary);
+}
+
+.tool-code.error {
+ background: var(--error-bg);
+ border-color: var(--error-border);
+ color: var(--error-text);
+}
+
+/* Bash Tool Component - uses shared tool styles */
+.bash-tool {
+ /* Alias for backwards compatibility */
+ background: var(--gray-100);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+ width: 100%;
+}
+
+.dark .bash-tool {
+ background: var(--gray-800);
+}
+
+.bash-tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.bash-tool-header:hover {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 0.5rem;
+}
+
+.dark .bash-tool-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.bash-tool-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.bash-tool-emoji {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.bash-tool-emoji.running {
+ animation: rotate 1s linear infinite;
+}
+
+.bash-tool-command {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.bash-tool-running {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-style: italic;
+ flex-shrink: 0;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.bash-tool-success {
+ color: var(--success-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.bash-tool-error {
+ color: var(--error-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.bash-tool-cancelled {
+ color: var(--error-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.bash-tool-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.bash-tool-toggle:hover {
+ color: var(--text-primary);
+}
+
+.bash-tool-details {
+ padding: 0 1rem 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.bash-tool-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.bash-tool-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.bash-tool-time {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ font-weight: normal;
+}
+
+.bash-tool-code {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--bg-base);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-primary);
+}
+
+.bash-tool-code.error {
+ background: var(--error-bg);
+ border-color: var(--error-border);
+ color: var(--error-text);
+}
+
+/* Patch Tool */
+.patch-tool {
+ background: var(--gray-100);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+ width: 100%;
+}
+
+.dark .patch-tool {
+ background: var(--gray-800);
+}
+
+.patch-tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.patch-tool-header:hover {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 0.5rem;
+}
+
+.dark .patch-tool-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.patch-tool-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.patch-tool-emoji {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.patch-tool-emoji.running {
+ animation: rotate 1s linear infinite;
+}
+
+.patch-tool-filename {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.patch-tool-running {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-style: italic;
+ flex-shrink: 0;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.patch-tool-success {
+ color: var(--success-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.patch-tool-error {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.patch-tool-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.patch-tool-toggle:hover {
+ color: var(--text-primary);
+}
+
+.patch-tool-details {
+ padding: 0 1rem 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.patch-tool-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.patch-tool-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.patch-tool-time {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ font-weight: normal;
+}
+
+.patch-tool-diff {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--bg-base);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ line-height: 1.4;
+}
+
+.patch-diff-line {
+ white-space: pre;
+ display: block;
+}
+
+.patch-diff-addition {
+ background: rgba(34, 197, 94, 0.1);
+ color: #16a34a;
+}
+
+.dark .patch-diff-addition {
+ background: rgba(34, 197, 94, 0.15);
+ color: #86efac;
+}
+
+.patch-diff-deletion {
+ background: rgba(239, 68, 68, 0.1);
+ color: #dc2626;
+}
+
+.dark .patch-diff-deletion {
+ background: rgba(239, 68, 68, 0.15);
+ color: #fca5a5;
+}
+
+.patch-diff-hunk {
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ font-weight: 500;
+}
+
+.patch-diff-header {
+ color: var(--text-tertiary);
+ font-style: italic;
+}
+
+.patch-tool-error-message {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-secondary);
+}
+
+/* Screenshot Tool */
+.screenshot-tool {
+ background: var(--gray-100);
+ border-radius: 0.5rem;
+ margin: 0.5rem 0;
+ width: 100%;
+}
+
+.dark .screenshot-tool {
+ background: var(--gray-800);
+}
+
+.screenshot-tool-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.screenshot-tool-header:hover {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 0.5rem;
+}
+
+.dark .screenshot-tool-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.screenshot-tool-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.screenshot-tool-emoji {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.screenshot-tool-emoji.running {
+ animation: rotate 1s linear infinite;
+}
+
+.screenshot-tool-filename {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.screenshot-tool-running {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-style: italic;
+ flex-shrink: 0;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.screenshot-tool-success {
+ color: var(--success-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.screenshot-tool-error {
+ color: var(--error-text);
+ font-size: 0.875rem;
+ flex-shrink: 0;
+}
+
+.screenshot-tool-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.screenshot-tool-toggle:hover {
+ color: var(--text-primary);
+}
+
+.screenshot-tool-details {
+ padding: 0 1rem 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.screenshot-tool-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.screenshot-tool-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.screenshot-tool-time {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ font-weight: normal;
+}
+
+.screenshot-tool-image-container {
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ overflow: hidden;
+ background: var(--bg-base);
+}
+
+.screenshot-tool-image-container a {
+ display: block;
+}
+
+.screenshot-tool-image-container img {
+ display: block;
+ border-radius: 0.25rem;
+}
+
+.screenshot-tool-error-message {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ margin: 0;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--error-text);
+}
+
+/* Message Input */
+.message-input-container {
+ flex: 0 0 auto;
+ background: var(--bg-base);
+ border-top: 1px solid var(--border);
+ padding: 1rem;
+ padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
+ padding-left: max(1rem, env(safe-area-inset-left));
+ padding-right: max(1rem, env(safe-area-inset-right));
+}
+
+.message-input-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.5rem;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.message-textarea {
+ flex: 1;
+ min-height: 44px;
+ max-height: 200px;
+ padding: 0.625rem 1rem;
+ border: 1.5px solid var(--border);
+ border-radius: 4px;
+ resize: none;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-family: inherit;
+ font-size: 16px; /* Must be 16px+ to prevent iOS zoom on focus */
+ line-height: 1.5;
+ transition:
+ border-color 0.2s,
+ box-shadow 0.2s;
+}
+
+.message-textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
+}
+
+.message-textarea:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.message-send-btn svg {
+ transform: rotate(-90deg);
+}
+
+.message-textarea::placeholder {
+ color: var(--text-tertiary);
+}
+
+.message-send-btn {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ margin-bottom: 0.25rem;
+ padding: 0;
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ background-color 0.2s,
+ transform 0.1s;
+ cursor: pointer;
+}
+
+.message-send-btn:hover:not(:disabled) {
+ background: var(--primary-dark);
+ transform: scale(1.05);
+}
+
+.message-send-btn:active:not(:disabled) {
+ transform: scale(0.95);
+}
+
+.message-send-btn:disabled {
+ background: var(--gray-300);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.dark .message-send-btn:disabled {
+ background: var(--gray-600);
+}
+
+/* Voice input button */
+.message-voice-btn {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ margin-bottom: 0.25rem;
+ padding: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ border: none;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ background-color 0.2s,
+ color 0.2s,
+ transform 0.1s;
+ cursor: pointer;
+}
+
+.message-voice-btn:hover:not(:disabled):not(.listening) {
+ background: var(--gray-100);
+ color: var(--text-primary);
+}
+
+.dark .message-voice-btn:hover:not(:disabled):not(.listening) {
+ background: var(--gray-700);
+}
+
+.message-voice-btn:active:not(:disabled):not(.listening) {
+ transform: scale(0.95);
+}
+
+.message-voice-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+
+.message-voice-btn.listening,
+.message-voice-btn.listening:hover {
+ background: #dc2626;
+ color: white;
+ animation: pulse-voice 1.5s ease-in-out infinite;
+}
+
+.dark .message-voice-btn.listening,
+.dark .message-voice-btn.listening:hover {
+ background: #ef4444;
+ color: white;
+}
+
+@keyframes pulse-voice {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.1);
+ }
+}
+
+/* Drag and drop styles */
+.message-input-container {
+ position: relative;
+}
+
+.message-input-container.drag-over {
+ border-color: var(--primary);
+}
+
+.drag-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(37, 99, 235, 0.1);
+ border: 2px dashed var(--primary);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.drag-overlay-content {
+ background: var(--primary);
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.modal {
+ background: var(--bg-base);
+ border-radius: 0.5rem;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ max-width: 28rem;
+ width: 100%;
+ max-height: 80vh;
+ overflow: hidden;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.modal-body {
+ padding: 1rem;
+}
+
+/* Form Elements */
+label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+}
+
+select {
+ width: 100%;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ font-family: inherit;
+ font-size: 1rem;
+ color: var(--text-primary);
+}
+
+select:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
+}
+
+select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Loading Spinner */
+.spinner {
+ width: 2rem;
+ height: 2rem;
+ border: 2px solid transparent;
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.spinner-small {
+ width: 1rem;
+ height: 1rem;
+ border-width: 2px;
+ border-top-color: currentColor;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Loading/Error States */
+.loading-container,
+.error-container {
+ height: 100vh; /* Fallback for older browsers */
+ height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.loading-content,
+.error-content {
+ text-align: center;
+}
+
+/* Unified Status Bar */
+.status-bar {
+ flex: 0 0 auto;
+ background: var(--bg-base);
+ border-top: 1px solid var(--border);
+ padding: 0.5rem 1rem;
+ padding-left: max(1rem, env(safe-area-inset-left));
+ padding-right: max(1rem, env(safe-area-inset-right));
+ min-height: 2.5rem;
+ display: flex;
+ align-items: center;
+}
+
+.status-bar-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 0.75rem;
+}
+
+.status-message {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.status-message.status-ready {
+ color: var(--text-tertiary);
+}
+
+.status-message.status-warning {
+ color: var(--blue-text);
+ font-weight: 500;
+}
+
+.status-message.status-error {
+ color: var(--error-text);
+ font-weight: 500;
+}
+
+.animated-working {
+ display: inline;
+}
+
+.animated-working .bold-letter {
+ font-weight: 700;
+}
+
+.status-button {
+ padding: 0.375rem 0.875rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ white-space: nowrap;
+ border: none;
+ cursor: pointer;
+}
+
+.status-button svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+.status-button-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.status-button-primary:hover {
+ background: var(--primary-dark);
+}
+
+.status-button-cancel {
+ background: var(--error-bg);
+ color: var(--error-text);
+ border: 1px solid var(--error-border);
+}
+
+.status-button-cancel:hover:not(:disabled) {
+ background: var(--error-text);
+ color: white;
+}
+
+.status-button-cancel:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.status-button-text {
+ background: transparent;
+ color: var(--text-secondary);
+ padding: 0.25rem;
+}
+
+.status-button-text:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+/* Legacy disconnect/error banner classes (kept for compatibility but unused) */
+.disconnect-banner {
+ background: var(--gray-100);
+ border-top: 1px solid var(--border);
+ padding: 0.5rem 1rem;
+}
+
+.dark .disconnect-banner {
+ background: var(--gray-800);
+}
+
+.disconnect-banner-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+}
+
+.disconnect-message {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ margin: 0;
+}
+
+.btn-reconnect {
+ padding: 0.25rem 0.75rem;
+ background: var(--primary);
+ color: white;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+}
+
+.btn-reconnect:hover {
+ background: var(--primary-dark);
+}
+
+.error-banner {
+ background: var(--error-bg);
+ border-top: 1px solid var(--error-border);
+ padding: 0.75rem 1rem;
+}
+
+.error-banner-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.error-message {
+ color: var(--error-text);
+ font-size: 0.875rem;
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-secondary);
+}
+
+.empty-state-content {
+ text-align: center;
+}
+
+/* Scrollbar */
+.scrollable {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+}
+
+.scrollable::-webkit-scrollbar {
+ width: 6px;
+}
+
+.scrollable::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scrollable::-webkit-scrollbar-thumb {
+ background-color: rgba(156, 163, 175, 0.5);
+ border-radius: 3px;
+}
+
+.scrollable::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(156, 163, 175, 0.8);
+}
+
+/* SVG Icons */
+svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ display: block;
+}
+
+.icon-sm svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+/* Utility Classes */
+.relative {
+ position: relative;
+}
+
+.overflow-auto {
+ overflow: auto;
+}
+
+.overflow-hidden {
+ overflow: hidden;
+}
+
+.rounded {
+ border-radius: 0.25rem;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.border {
+ border: 1px solid var(--border);
+}
+
+.min-h-0 {
+ min-height: 0;
+}
+
+/* Overflow Menu */
+.overflow-menu {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ right: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ min-width: 10rem;
+ z-index: 50;
+}
+
+.overflow-menu-item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ text-align: left;
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.overflow-menu-item:hover {
+ background: var(--bg-hover);
+}
+
+.overflow-menu-item:first-child {
+ border-top-left-radius: 0.5rem;
+ border-top-right-radius: 0.5rem;
+}
+
+.overflow-menu-item:last-child {
+ border-bottom-left-radius: 0.5rem;
+ border-bottom-right-radius: 0.5rem;
+}
+
+/* Diff display styling */
+.diff-display {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ line-height: 1.5;
+ background: var(--bg-base);
+ padding: 1rem;
+ border-radius: 0.25rem;
+ overflow-x: auto;
+ white-space: pre;
+ color: var(--text-primary);
+}
+
+/* Responsive adjustments */
+@media (min-width: 768px) {
+ .drawer {
+ position: static;
+ transform: translateX(0) !important;
+ }
+
+ .hide-on-desktop {
+ display: none !important;
+ }
+
+ .backdrop {
+ display: none !important;
+ }
+}
+
+/* Rotation animation for running tools */
+@keyframes rotate {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Scroll to bottom button */
+.scroll-to-bottom-button {
+ position: absolute;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 2rem;
+ padding: 0.5rem 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transition: all 0.2s ease;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ z-index: 10;
+}
+
+.scroll-to-bottom-button:hover {
+ background: var(--bg-hover);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.scroll-to-bottom-button:active {
+ transform: translateX(-50%) scale(0.95);
+}
+
+/* Wrapper for messages area to position scroll-to-bottom button */
+.messages-area-wrapper {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0; /* Important for flex children to shrink properly */
+ overflow: hidden;
+}
+
+.messages-container {
+ height: 100%;
+}
+
+/* Status bar configuration controls for empty conversations */
+.status-bar-config {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+}
+
+.status-bar-new-conversation {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+/* On smaller screens, allow the fields to stack vertically */
+@media (max-width: 600px) {
+ .status-bar-new-conversation {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .status-field {
+ width: 100%;
+ }
+}
+
+.status-bar-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Flexible containers for status fields that adapt to content */
+.status-field {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ min-width: 0; /* Allow flex items to shrink below content size */
+}
+
+.status-field-label {
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.status-field-model {
+ flex: 1 1 auto;
+ min-width: 150px;
+ max-width: 300px;
+}
+
+.status-field-cwd {
+ flex: 1 1 auto;
+ min-width: 180px;
+ max-width: 400px;
+}
+
+/* Compact clickable chips for model and cwd */
+.status-chip {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: normal; /* Allow text to wrap */
+ word-break: break-word; /* Break long words if needed */
+ overflow-wrap: break-word;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ line-height: 1.3; /* Better line height for multi-line text */
+ min-height: 1.75rem; /* Minimum height to accommodate wrapped text */
+}
+
+.status-chip:hover:not(:disabled) {
+ background: var(--bg-secondary);
+ border-color: var(--blue-text);
+}
+
+.status-chip:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.status-chip-error {
+ border-color: var(--red-text, #dc2626);
+ background: rgba(220, 38, 38, 0.1);
+}
+
+.status-field-error {
+ position: relative;
+}
+
+.status-input-error {
+ border-color: var(--red-text, #dc2626);
+}
+
+.status-select {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--blue-text);
+ border-radius: 0.25rem;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: border-color 0.2s;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.status-select:hover:not(:disabled) {
+ border-color: var(--blue-text);
+}
+
+.status-select:focus {
+ outline: none;
+ border-color: var(--blue-text);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.status-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.status-input {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--blue-text);
+ border-radius: 0.25rem;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-size: 0.75rem;
+ font-family: var(--font-mono);
+ transition: border-color 0.2s;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.status-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.6;
+}
+
+.status-input:hover:not(:disabled) {
+ border-color: var(--blue-text);
+}
+
+.status-input:focus {
+ outline: none;
+ border-color: var(--blue-text);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.status-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Responsive adjustments for status bar controls */
+@media (max-width: 768px) {
+ .status-bar-config {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .status-bar-controls {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .status-field-model {
+ min-width: 120px;
+ max-width: 200px;
+ }
+
+ .status-field-cwd {
+ min-width: 140px;
+ max-width: 250px;
+ }
+}
+
+/* Directory Picker Modal */
+.directory-picker-modal {
+ max-width: 32rem;
+ max-height: 70vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.directory-picker-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.directory-picker-input-container {
+ margin-bottom: 0.75rem;
+}
+
+.directory-picker-input {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+}
+
+.directory-picker-input:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
+}
+
+.directory-picker-current {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ margin-bottom: 0.5rem;
+ padding: 0.25rem 0;
+}
+
+.directory-picker-filter {
+ color: var(--primary);
+ font-weight: 500;
+}
+
+.directory-picker-error {
+ padding: 0.5rem 0.75rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ border-radius: 0.25rem;
+ color: var(--error-text);
+ font-size: 0.875rem;
+ margin-bottom: 0.75rem;
+}
+
+.directory-picker-loading {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1rem;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.directory-picker-list {
+ flex: 1;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ min-height: 200px;
+ max-height: 300px;
+}
+
+.directory-picker-entry {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: background-color 0.15s;
+}
+
+.directory-picker-entry:last-child {
+ border-bottom: none;
+}
+
+.directory-picker-entry:hover {
+ background: var(--bg-tertiary);
+}
+
+.directory-picker-entry-parent {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.directory-picker-icon {
+ width: 1rem;
+ height: 1rem;
+ flex-shrink: 0;
+ color: var(--text-secondary);
+}
+
+.directory-picker-empty {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: 0.875rem;
+}
+
+.directory-picker-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Context Usage Bar */
+/* Status bar for active conversation */
+.status-bar-active {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.status-working-group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.status-stop-button {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border: none;
+ border-radius: 0.25rem;
+ background: var(--error-bg);
+ color: var(--error-text);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.status-stop-button:hover:not(:disabled) {
+ background: var(--error-text);
+ color: white;
+}
+
+.status-stop-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.status-stop-button svg {
+ width: 0.75rem;
+ height: 0.75rem;
+ flex-shrink: 0;
+}
+
+.status-stop-label {
+ white-space: nowrap;
+}
+
+/* Hide stop label on small screens */
+@media (max-width: 500px) {
+ .status-stop-label {
+ display: none;
+ }
+ .status-stop-button {
+ padding: 0.25rem;
+ }
+}
+
+.context-usage-bar {
+ width: 60px;
+ height: 6px;
+ background: var(--bg-tertiary);
+ border-radius: 3px;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.context-usage-fill {
+ height: 100%;
+ border-radius: 3px;
+ transition:
+ width 0.3s ease,
+ background-color 0.3s ease;
+}
+
+/* Mobile optimizations for tighter spacing */
+@media (max-width: 767px) {
+ .header {
+ padding: 0.375rem 0.75rem;
+ padding-top: calc(0.375rem + env(safe-area-inset-top, 0px));
+ }
+
+ .status-bar {
+ padding: 0.375rem 0.75rem;
+ min-height: 2rem;
+ }
+
+ .message-input-container {
+ padding: 0.5rem 0.75rem;
+ padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
+ }
+}
+
+/* Print styles - looks like the chat window, just without chrome */
+@media print {
+ /* Reset page layout */
+ html,
+ body {
+ height: auto !important;
+ overflow: visible !important;
+ background: white !important;
+ }
+
+ /* Hide interactive chrome but keep the header title */
+ .status-bar,
+ .message-input-container,
+ .drawer,
+ .backdrop,
+ .scroll-to-bottom-button,
+ .overflow-menu,
+ .context-menu,
+ .modal-overlay {
+ display: none !important;
+ }
+
+ /* Hide header buttons but keep the title */
+ .header-actions,
+ .header .btn-icon {
+ display: none !important;
+ }
+
+ /* Simplify header for print */
+ .header {
+ background: white !important;
+ border-bottom: 1px solid #e5e7eb !important;
+ padding: 0.75rem 1rem !important;
+ }
+
+ .header-title {
+ font-size: 1rem !important;
+ }
+
+ /* Reset app container for print */
+ .app-container {
+ display: block !important;
+ height: auto !important;
+ background: white !important;
+ }
+
+ .main-content {
+ display: block !important;
+ height: auto !important;
+ }
+
+ /* Reset messages area to flow naturally */
+ .messages-area-wrapper {
+ height: auto !important;
+ overflow: visible !important;
+ position: static !important;
+ background: white !important;
+ }
+
+ .messages-container {
+ height: auto !important;
+ overflow: visible !important;
+ position: static !important;
+ background: white !important;
+ }
+
+ .messages-list {
+ background: white !important;
+ }
+
+ /* Hide empty state in print */
+ .empty-state {
+ display: none !important;
+ }
+
+ /* Keep message styling, just add page break hints */
+ .message {
+ page-break-inside: avoid;
+ }
+
+ /* Tool results */
+ .tool-result-details {
+ page-break-inside: avoid;
+ }
+
+ /* Code blocks */
+ pre,
+ code {
+ page-break-inside: avoid;
+ }
+
+ /* Screenshots and images - ensure they fit */
+ .screenshot-preview,
+ .screenshot-image,
+ .read-image-preview {
+ max-width: 100% !important;
+ height: auto !important;
+ page-break-inside: avoid;
+ }
+}
+/* Diff Viewer Overlay */
+.diff-viewer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ padding-top: max(1rem, env(safe-area-inset-top));
+ padding-bottom: max(1rem, env(safe-area-inset-bottom));
+}
+
+.diff-viewer-container {
+ background: var(--bg-base);
+ border-radius: 0.5rem;
+ width: calc(100% - 2rem);
+ max-width: none;
+ height: calc(100% - 2rem);
+ max-height: none;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
+}
+
+.diff-viewer-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.diff-viewer-header-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.diff-viewer-header-left {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.diff-viewer-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.diff-viewer-mode-toggle {
+ display: flex;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ overflow: hidden;
+}
+
+.diff-viewer-mode-btn {
+ padding: 0.25rem 0.5rem;
+ border: none;
+ background: var(--bg-base);
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 0.875rem;
+}
+
+.diff-viewer-mode-btn:first-child {
+ border-right: 1px solid var(--border-color);
+}
+
+.diff-viewer-mode-btn.active {
+ background: var(--blue-bg);
+ color: var(--blue-text);
+}
+
+.diff-viewer-mode-btn:hover:not(.active) {
+ background: var(--bg-tertiary);
+}
+
+.diff-viewer-nav-buttons {
+ display: flex;
+ gap: 0.125rem;
+}
+
+.diff-viewer-nav-btn {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border-color);
+ background: var(--bg-base);
+ color: var(--text-primary);
+ cursor: pointer;
+ border-radius: 0.25rem;
+ font-size: 0.875rem;
+}
+
+.diff-viewer-nav-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.diff-viewer-nav-btn:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+}
+
+.diff-viewer-expand-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border-color);
+ background: var(--bg-base);
+ color: var(--text-primary);
+ cursor: pointer;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.diff-viewer-expand-btn:hover {
+ background: var(--bg-tertiary);
+}
+
+.diff-viewer-expand-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ text-align: left;
+}
+
+.diff-viewer-selectors {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ padding-top: 0.25rem;
+}
+
+.diff-viewer-select {
+ flex: 1;
+ min-width: 120px;
+ max-width: 300px;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+}
+
+.diff-viewer-close {
+ width: 2rem;
+ height: 2rem;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 1.5rem;
+ cursor: pointer;
+ border-radius: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.diff-viewer-close:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.diff-viewer-error {
+ padding: 0.5rem 1rem;
+ background: var(--error-bg);
+ color: var(--error-text);
+ font-size: 0.875rem;
+}
+
+.diff-viewer-toast {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ z-index: 9999;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ animation: toast-fade-in 0.2s ease;
+ color: #ffffff;
+}
+
+.diff-viewer-toast-saving {
+ background: #1976d2;
+}
+
+.diff-viewer-toast-saved {
+ background: #2e7d32;
+}
+
+.diff-viewer-toast-error {
+ background: #d32f2f;
+}
+
+@keyframes toast-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.diff-viewer-content {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+.diff-viewer-loading,
+.diff-viewer-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-secondary);
+ gap: 0.5rem;
+}
+
+.diff-viewer-hint {
+ font-size: 0.875rem;
+ opacity: 0.7;
+}
+
+.diff-viewer-editor {
+ height: 100%;
+ width: 100%;
+}
+
+.diff-viewer-comment-badge {
+ position: absolute;
+ bottom: 1rem;
+ left: 1rem;
+ padding: 0.5rem 1rem;
+ background: var(--blue-bg);
+ color: var(--blue-text);
+ border-radius: 1rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+/* Comment dialog */
+.diff-viewer-comment-dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 90%;
+ max-width: 500px;
+ background: var(--bg-base);
+ border: 1px solid var(--border-color);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 1001;
+}
+
+.diff-viewer-comment-dialog h4 {
+ margin: 0 0 0.75rem 0;
+ color: var(--text-primary);
+}
+
+.diff-viewer-selected-text {
+ margin: 0 0 0.75rem 0;
+ padding: 0.5rem;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-family: monospace;
+ max-height: 150px;
+ overflow: auto;
+ white-space: pre;
+}
+
+.diff-viewer-comment-input {
+ width: 100%;
+ min-height: 100px;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ resize: vertical;
+}
+
+.diff-viewer-comment-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.diff-viewer-btn {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ font-size: 0.875rem;
+ cursor: pointer;
+}
+
+.diff-viewer-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.diff-viewer-btn-primary {
+ background: var(--blue-bg);
+ color: var(--blue-text);
+}
+
+.diff-viewer-btn-primary:hover:not(:disabled) {
+ filter: brightness(0.95);
+}
+
+.diff-viewer-btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.diff-viewer-btn-secondary:hover:not(:disabled) {
+ background: var(--bg-secondary);
+}
+
+/* Monaco comment glyph decoration */
+/* Mobile optimizations for diff viewer */
+@media (max-width: 767px) {
+ .diff-viewer-overlay {
+ padding: 0;
+ }
+
+ .diff-viewer-container {
+ border-radius: 0;
+ max-height: 100%;
+ height: 100%;
+ }
+
+ .diff-viewer-header {
+ padding: 0.375rem 0.5rem;
+ gap: 0.375rem;
+ }
+
+ .diff-viewer-header-row {
+ gap: 0.375rem;
+ }
+
+ .diff-viewer-selectors {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .diff-viewer-select {
+ max-width: none;
+ width: 100%;
+ }
+
+ .diff-viewer-title {
+ font-size: 0.875rem;
+ }
+
+ .diff-viewer-mode-btn {
+ padding: 0.25rem 0.375rem;
+ font-size: 0.75rem;
+ }
+
+ .diff-viewer-nav-btn {
+ padding: 0.25rem 0.375rem;
+ font-size: 0.75rem;
+ }
+
+ .diff-viewer-expand-btn {
+ font-size: 0.6875rem;
+ }
+
+ .diff-viewer-close {
+ width: 1.75rem;
+ height: 1.75rem;
+ font-size: 1.25rem;
+ }
+
+ /* Monaco editor mobile styles - hide gutters */
+ .diff-viewer-editor .monaco-editor .margin {
+ display: none !important;
+ }
+
+ .diff-viewer-editor .monaco-editor .editor-scrollable {
+ left: 0 !important;
+ }
+}
+
+/* Injected text indicator (for diff comments) */
+.injected-text-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ background: var(--blue-bg);
+ border-bottom: 1px solid var(--border-color);
+ gap: 0.5rem;
+}
+
+.injected-text-label {
+ font-size: 0.875rem;
+ color: var(--blue-text);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.injected-text-insert-btn {
+ padding: 0.375rem 0.75rem;
+ background: var(--blue-text);
+ color: white;
+ border: none;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.injected-text-insert-btn:hover {
+ filter: brightness(1.1);
+}
@@ -0,0 +1,125 @@
+// Types for Shelley UI
+import {
+ Conversation as GeneratedConversation,
+ ApiMessageForTS,
+ StreamResponseForTS,
+ Usage as GeneratedUsage,
+ MessageType as GeneratedMessageType,
+} from "./generated-types";
+
+// Re-export generated types
+export type Conversation = GeneratedConversation;
+export type Usage = GeneratedUsage;
+export type MessageType = GeneratedMessageType;
+
+// Extend the generated Message type with parsed data
+export interface Message extends Omit<ApiMessageForTS, "type"> {
+ type: MessageType;
+}
+
+// Go backend LLM struct format (capitalized field names)
+export interface LLMMessage {
+ Role: number; // 0 = user, 1 = assistant
+ Content: LLMContent[];
+ ToolUse?: unknown;
+}
+
+export interface LLMContent {
+ ID: string;
+ Type: number; // 2 = text, 3 = tool_use, 4 = tool_result, 5 = thinking
+ Text?: string;
+ ToolName?: string;
+ ToolInput?: unknown;
+ ToolResult?: LLMContent[];
+ ToolError?: boolean;
+ // Other fields from Go struct
+ MediaType?: string;
+ Thinking?: string;
+ Data?: string;
+ Signature?: string;
+ ToolUseID?: string;
+ ToolUseStartTime?: string | null;
+ ToolUseEndTime?: string | null;
+ Display?: unknown;
+ Cache?: boolean;
+}
+
+// API types
+export interface Model {
+ id: string;
+ ready: boolean;
+ max_context_tokens?: number;
+}
+
+export interface ChatRequest {
+ message: string;
+ model?: string;
+ cwd?: string;
+}
+// StreamResponse represents the streaming response format
+export interface StreamResponse extends Omit<StreamResponseForTS, "messages"> {
+ messages: Message[];
+ context_window_size?: number;
+}
+
+// Link represents a custom link that can be added to the UI
+export interface Link {
+ title: string;
+ icon_svg?: string; // SVG path data for the icon
+ url: string;
+}
+
+// InitData is injected into window by the server
+export interface InitData {
+ models: Model[];
+ default_model: string;
+ default_cwd?: string;
+ home_dir?: string;
+ hostname?: string;
+ terminal_url?: string;
+ links?: Link[];
+}
+
+// Extend Window interface to include our init data
+declare global {
+ interface Window {
+ __SHELLEY_INIT__?: InitData;
+ }
+}
+
+// Git diff types
+export interface GitDiffInfo {
+ id: string;
+ message: string;
+ author: string;
+ timestamp: string;
+ filesCount: number;
+ additions: number;
+ deletions: number;
+}
+
+export interface GitFileInfo {
+ path: string;
+ status: "added" | "modified" | "deleted";
+ additions: number;
+ deletions: number;
+}
+
+export interface GitFileDiff {
+ path: string;
+ oldContent: string;
+ newContent: string;
+}
+
+// Comment for diff viewer
+export interface DiffComment {
+ id: string;
+ line: number;
+ side: "left" | "right";
+ text: string;
+ selectedText?: string;
+ startLine?: number;
+ endLine?: number;
+ filePath: string;
+ diffId: string;
+}
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["build.ts"]
+}
@@ -0,0 +1,35 @@
+package version
+
+import (
+ "runtime/debug"
+)
+
+// Info holds build information from runtime/debug.ReadBuildInfo
+type Info struct {
+ Commit string `json:"commit,omitempty"`
+ CommitTime string `json:"commit_time,omitempty"`
+ Modified bool `json:"modified,omitempty"`
+}
+
+// GetInfo returns build information using runtime/debug.ReadBuildInfo
+func GetInfo() Info {
+ var info Info
+
+ buildInfo, ok := debug.ReadBuildInfo()
+ if !ok {
+ return info
+ }
+
+ for _, setting := range buildInfo.Settings {
+ switch setting.Key {
+ case "vcs.revision":
+ info.Commit = setting.Value
+ case "vcs.time":
+ info.CommitTime = setting.Value
+ case "vcs.modified":
+ info.Modified = setting.Value == "true"
+ }
+ }
+
+ return info
+}