From 4639efb4232642e78d7fc8b98ba554f57c623f57 Mon Sep 17 00:00:00 2001 From: Joohwi Lee Date: Fri, 5 Dec 2025 22:30:34 -0800 Subject: [PATCH] feat: add VCS watcher, SDK-compatible types, and protocol docs - Add VCS branch watcher using fsnotify to monitor .git/HEAD changes - Publishes vcs.branch.updated events on branch change - Integrated into server lifecycle (start/shutdown) - Includes comprehensive unit tests - Fix SDK compatibility issues in types: - Change smallModel, toolcall, topP to camelCase for TS compatibility - Fix ModelCost to use nested cache object (cache.read/write) - Add missing part types: SnapshotPart, PatchPart, AgentPart, SubtaskPart, RetryPart - Add TUI event types: - tui.prompt.append, tui.command.execute, tui.toast.show - vcs.branch.updated, pty.* events, command.executed - Update TUI handlers to publish SSE events - Add comprehensive protocol documentation: - docs/opencode-server-endpoints.md - Complete endpoint reference - docs/tui-event-specification.md - Event protocol with AI SDK comparison --- docs/opencode-server-endpoints.md | 2108 +++++++++++++++++ docs/tui-event-specification.md | 1089 +++++++++ go-opencode/go.mod | 1 + go-opencode/go.sum | 2 + go-opencode/internal/event/bus.go | 17 + go-opencode/internal/event/types.go | 71 + .../internal/server/handlers_config.go | 40 +- go-opencode/internal/server/handlers_file.go | 15 +- go-opencode/internal/server/handlers_tui.go | 71 +- go-opencode/internal/server/server.go | 15 + go-opencode/internal/vcs/watcher.go | 186 ++ go-opencode/internal/vcs/watcher_test.go | 287 +++ go-opencode/pkg/types/config.go | 8 +- go-opencode/pkg/types/parts.go | 136 ++ 14 files changed, 4021 insertions(+), 25 deletions(-) create mode 100644 docs/opencode-server-endpoints.md create mode 100644 docs/tui-event-specification.md create mode 100644 go-opencode/internal/vcs/watcher.go create mode 100644 go-opencode/internal/vcs/watcher_test.go diff --git a/docs/opencode-server-endpoints.md b/docs/opencode-server-endpoints.md new file mode 100644 index 00000000000..6aae8c0db58 --- /dev/null +++ b/docs/opencode-server-endpoints.md @@ -0,0 +1,2108 @@ +# OpenCode Server Endpoints Reference + +**Version:** 1.0.0 +**Last Updated:** 2025-12-05 + +This document provides complete specifications for all OpenCode server HTTP endpoints. Use this reference to implement a TUI-compatible OpenCode server. + +## Table of Contents + +- [Overview](#overview) +- [TUI Connection Sequence](#tui-connection-sequence) +- [Response Formats](#response-formats) +- [Event Streaming (SSE)](#event-streaming-sse) +- [Session Endpoints](#session-endpoints) +- [Message Endpoints](#message-endpoints) +- [Configuration Endpoints](#configuration-endpoints) +- [Provider Endpoints](#provider-endpoints) +- [File Endpoints](#file-endpoints) +- [Search Endpoints](#search-endpoints) +- [MCP Endpoints](#mcp-endpoints) +- [Command Endpoints](#command-endpoints) +- [TUI Control Endpoints](#tui-control-endpoints) +- [Client Tools Endpoints](#client-tools-endpoints) +- [Utility Endpoints](#utility-endpoints) + +--- + +## Overview + +### Server Configuration + +| Setting | Value | Description | +|---------|-------|-------------| +| Protocol | HTTP/1.1 | Standard HTTP | +| Host | `127.0.0.1` | Localhost only | +| Port | Dynamic (default 8080) | Configured at startup | +| Content-Type | `application/json` | JSON for all endpoints except SSE | + +### File References + +| Component | File Path | +|-----------|-----------| +| Routes | `go-opencode/internal/server/routes.go` | +| SSE Handler | `go-opencode/internal/server/sse.go` | +| Session Handlers | `go-opencode/internal/server/handlers_session.go` | +| Message Handlers | `go-opencode/internal/server/handlers_message.go` | +| Config Handlers | `go-opencode/internal/server/handlers_config.go` | +| File Handlers | `go-opencode/internal/server/handlers_file.go` | +| TUI Handlers | `go-opencode/internal/server/handlers_tui.go` | + +--- + +## TUI Connection Sequence + +When a TUI client connects, endpoints are called in this order: + +``` +1. GET /event ← Establish SSE connection (long-running) + ↓ (receives server.connected event) +2. GET /config/providers ← Get available model providers +3. GET /provider ← Get provider list with connection status +4. GET /agent ← Get available agents +5. GET /config ← Get application configuration +6. GET /mcp ← Get MCP server status +7. GET /lsp ← Get LSP server status +8. GET /command ← Get available slash commands +9. GET /session ← Get existing sessions +10. GET /formatter ← Get formatter status +11. GET /provider/auth ← Get authentication methods +12. GET /session/status ← Get active session statuses +13. GET /vcs ← Get VCS (git) branch info +``` + +### Creating a Session and Sending a Message + +``` +1. POST /session ← Create new session + ↓ (receives session.created event via SSE) +2. POST /session/{id}/message ← Send user message + ↓ (receives streaming events via SSE) + ↓ message.updated, message.part.updated, session.status, etc. +3. GET /session/{id}/todo ← Get session todos +4. GET /session/{id}/diff ← Get file diffs +5. GET /session/{id}/message ← Get all messages +6. GET /session/{id} ← Get session details +``` + +### Permission Flow + +``` +1. Tool requests permission ← permission.updated event via SSE +2. POST /session/{id}/permissions/{permissionID} + ↓ (grants or denies permission) + ↓ permission.replied event via SSE +3. Tool continues or aborts +``` + +--- + +## Response Formats + +### Success Response + +```json +{ + "success": true, + "data": { ... } +} +``` + +Or for list endpoints, the array is returned directly: +```json +[ ... ] +``` + +### Error Response + +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message" + } +} +``` + +| Error Code | HTTP Status | Description | +|------------|-------------|-------------| +| `INVALID_REQUEST` | 400 | Malformed request or missing required fields | +| `NOT_FOUND` | 404 | Resource not found | +| `INTERNAL_ERROR` | 500 | Server-side error | + +### Simple Success Response + +For endpoints that only return success/failure: +```json +{ + "success": true +} +``` + +--- + +## Event Streaming (SSE) + +### GET /event + +Establishes an SSE connection for real-time events. + +**Response Headers:** +``` +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +X-Accel-Buffering: no +``` + +**Event Format:** +``` +event: message +data: {"type":"event.type","properties":{...}} + +: heartbeat + +``` + +**Initial Event:** +```json +{"type": "server.connected", "properties": {}} +``` + +**Heartbeat:** Sent every 30 seconds as a comment (`: heartbeat\n\n`) + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `sessionID` | string | Optional. Filter events to specific session | +| `directory` | string | Optional. Working directory context | + +**File Reference:** `go-opencode/internal/server/sse.go:88-153` + +### GET /global/event + +Global event stream (cross-project events). + +Same format as `/event` but without session filtering. + +--- + +## Session Endpoints + +### GET /session + +List all sessions. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `directory` | string | No | Filter by directory | + +**Response:** +```json +[ + { + "id": "ses_xxxx", + "projectID": "hash", + "directory": "/path/to/project", + "parentID": null, + "title": "Session Title", + "version": "local", + "summary": { + "additions": 0, + "deletions": 0, + "files": 0, + "diffs": [] + }, + "share": null, + "time": { + "created": 1764964062216, + "updated": 1764964062216 + } + } +] +``` + +**Session Object Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Session ID (format: `ses_xxxx`) | +| `projectID` | string | Hash of project directory | +| `directory` | string | Absolute path to working directory | +| `parentID` | string? | Parent session ID (for forks) | +| `title` | string | Session display title | +| `version` | string | Always "local" for local sessions | +| `summary.additions` | number | Total lines added | +| `summary.deletions` | number | Total lines deleted | +| `summary.files` | number | Number of files changed | +| `summary.diffs` | FileDiff[] | Array of file diffs | +| `share` | object? | `{url: string}` if shared | +| `time.created` | number | Unix timestamp (ms) | +| `time.updated` | number | Unix timestamp (ms) | + +**File Reference:** `go-opencode/internal/server/handlers_session.go:22-39` + +--- + +### POST /session + +Create a new session. + +**Request Body:** +```json +{ + "directory": "/path/to/project", + "title": "Optional Session Title" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `directory` | string | No | Working directory (uses context if omitted) | +| `title` | string | No | Custom title | + +**Response:** Session object (same as GET /session item) + +**SSE Event:** `session.created` with `{info: Session}` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:42-76` + +--- + +### GET /session/{sessionID} + +Get a single session. + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `sessionID` | string | Session ID | + +**Response:** Session object + +**File Reference:** `go-opencode/internal/server/handlers_session.go:78-89` + +--- + +### PATCH /session/{sessionID} + +Update session metadata. + +**Request Body:** +```json +{ + "title": "New Title" +} +``` + +**Response:** Updated Session object + +**SSE Event:** `session.updated` with `{info: Session}` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:91-114` + +--- + +### DELETE /session/{sessionID} + +Delete a session. + +**Response:** +```json +{"success": true} +``` + +**SSE Event:** `session.deleted` with `{info: Session}` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:116-135` + +--- + +### GET /session/status + +Get status of all active sessions. + +**Response:** +```json +{ + "ses_xxxx": { + "type": "busy", + "attempt": 0, + "message": "", + "next": 0 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | `"idle"`, `"busy"`, or `"retry"` | +| `attempt` | number | Retry attempt count (for retry status) | +| `message` | string | Status message (for retry status) | +| `next` | number | Next retry timestamp (for retry status) | + +**Note:** Sessions not in the map are considered idle. + +**File Reference:** `go-opencode/internal/server/handlers_session.go:146-156` + +--- + +### GET /session/{sessionID}/children + +Get child sessions (forks). + +**Response:** +```json +[Session, Session, ...] +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:158-169` + +--- + +### POST /session/{sessionID}/fork + +Fork a session from a specific message. + +**Request Body:** +```json +{ + "messageID": "msg_xxxx" +} +``` + +**Response:** New Session object + +**SSE Event:** `session.created` with `{info: Session}` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:176-199` + +--- + +### POST /session/{sessionID}/abort + +Abort the current message processing. + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:201-211` + +--- + +### POST /session/{sessionID}/share + +Share a session publicly. + +**Response:** +```json +{ + "url": "https://opencode.ai/share/xxx" +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:213-224` + +--- + +### DELETE /session/{sessionID}/share + +Unshare a session. + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:226-236` + +--- + +### POST /session/{sessionID}/summarize + +Trigger session summarization. + +**Request Body:** +```json +{ + "providerID": "anthropic", + "modelID": "claude-sonnet-4-20250514" +} +``` + +**Response:** +```json +true +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:244-266` + +--- + +### POST /session/{sessionID}/init + +Initialize session (returns session info). + +**Response:** Session object + +**File Reference:** `go-opencode/internal/server/handlers_session.go:268-280` + +--- + +### GET /session/{sessionID}/diff + +Get file diffs for the session. + +**Response:** +```json +[ + { + "file": "/path/to/file.go", + "additions": 10, + "deletions": 5, + "before": "original content...", + "after": "new content..." + } +] +``` + +**FileDiff Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `file` | string | Absolute file path | +| `additions` | number | Lines added | +| `deletions` | number | Lines deleted | +| `before` | string? | Original content (if available) | +| `after` | string? | New content (if available) | + +**File Reference:** `go-opencode/internal/server/handlers_session.go:282-298` + +--- + +### GET /session/{sessionID}/todo + +Get todo items for the session. + +**Response:** +```json +[ + { + "id": "todo_xxxx", + "content": "Task description", + "status": "pending", + "priority": "medium" + } +] +``` + +**Todo Status Values:** `"pending"`, `"in_progress"`, `"completed"`, `"cancelled"` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:300-316` + +--- + +### POST /session/{sessionID}/revert + +Revert session to a previous state. + +**Request Body:** +```json +{ + "messageID": "msg_xxxx", + "partID": "prt_xxxx" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `messageID` | string | Yes | Message to revert to | +| `partID` | string | No | Specific part within message | + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:324-340` + +--- + +### POST /session/{sessionID}/unrevert + +Undo a revert operation. + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:342-352` + +--- + +### POST /session/{sessionID}/command + +Send a slash command. + +**Request Body:** +```json +{ + "command": "/review uncommitted" +} +``` + +**Response:** Command result object + +**File Reference:** `go-opencode/internal/server/handlers_session.go:359-376` + +--- + +### POST /session/{sessionID}/shell + +Run a shell command. + +**Request Body:** +```json +{ + "command": "ls -la", + "timeout": 30000 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `command` | string | Yes | Shell command to run | +| `timeout` | number | No | Timeout in milliseconds | + +**Response:** Shell execution result + +**File Reference:** `go-opencode/internal/server/handlers_session.go:383-401` + +--- + +### POST /session/{sessionID}/permissions/{permissionID} + +Respond to a permission request. + +**Request Body:** +```json +{ + "granted": true +} +``` + +**Response:** +```json +{"success": true} +``` + +**SSE Event:** `permission.replied` with: +```json +{ + "sessionID": "ses_xxxx", + "permissionID": "per_xxxx", + "response": "once" +} +``` + +**Response Values:** `"once"`, `"always"`, `"reject"` + +**File Reference:** `go-opencode/internal/server/handlers_session.go:408-441` + +--- + +## Message Endpoints + +### GET /session/{sessionID}/message + +Get all messages in a session. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | number | Maximum messages to return | + +**Response:** +```json +[ + { + "info": { + "id": "msg_xxxx", + "sessionID": "ses_xxxx", + "role": "user", + "time": { + "created": 1764964062240 + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-sonnet-4-20250514" + }, + "summary": { + "title": "Task title", + "diffs": [] + } + }, + "parts": [ + { + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "text", + "text": "Message content" + } + ] + } +] +``` + +**Message Object Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Message ID (format: `msg_xxxx`) | +| `sessionID` | string | Parent session ID | +| `role` | string | `"user"` or `"assistant"` | +| `time.created` | number | Unix timestamp (ms) | +| `time.updated` | number? | Last update timestamp | +| `agent` | string? | Agent name (user messages) | +| `model` | ModelRef? | Model reference (user messages) | +| `summary` | object? | Summary with title/diffs (user) | +| `parentID` | string? | Parent message ID (assistant) | +| `modelID` | string? | Model ID used (assistant) | +| `providerID` | string? | Provider ID used (assistant) | +| `mode` | string? | Agent mode (assistant) | +| `path` | object? | `{cwd, root}` paths (assistant) | +| `finish` | string? | Finish reason (assistant) | +| `cost` | number | Cost in USD (assistant) | +| `tokens` | TokenUsage? | Token counts (assistant) | +| `error` | MessageError? | Error info if failed | + +**TokenUsage Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `input` | number | Input tokens | +| `output` | number | Output tokens | +| `reasoning` | number | Reasoning tokens | +| `cache.read` | number | Cache read tokens | +| `cache.write` | number | Cache write tokens | + +**File Reference:** `go-opencode/internal/server/handlers_message.go:245-271` + +--- + +### POST /session/{sessionID}/message + +Send a message and get streaming response. + +**Request Body:** +```json +{ + "content": "Your prompt here", + "parts": [ + {"type": "text", "text": "Your prompt here"} + ], + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-sonnet-4-20250514" + }, + "tools": { + "read": true, + "write": false + }, + "files": [] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content` | string | Yes* | Message text (legacy format) | +| `parts` | array | Yes* | SDK format with text parts | +| `agent` | string | No | Agent to use | +| `model` | ModelRef | No | Model to use | +| `tools` | object | No | Tool enable/disable map | +| `files` | FilePart[] | No | Attached files | + +*Either `content` or `parts` is required. + +**Response Headers:** +``` +Content-Type: application/json +Transfer-Encoding: chunked +Cache-Control: no-cache +Connection: keep-alive +``` + +**Response (final):** +```json +{ + "info": Message, + "parts": Part[] +} +``` + +**SSE Events fired during processing:** +1. `message.updated` (user message) +2. `message.part.updated` (user text part) +3. `session.status` (type: "busy") +4. `session.updated` +5. `session.diff` (initial empty) +6. `message.created` (assistant message) +7. `message.part.updated` * N (streaming parts) +8. `message.updated` (assistant complete) +9. `session.status` (type: "idle") +10. `session.idle` + +**File Reference:** `go-opencode/internal/server/handlers_message.go:52-243` + +--- + +### GET /session/{sessionID}/message/{messageID} + +Get a single message with its parts. + +**Response:** +```json +{ + "info": Message, + "parts": Part[] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_message.go:273-294` + +--- + +## Configuration Endpoints + +### GET /config + +Get application configuration. + +**Response:** +```json +{ + "model": "anthropic/claude-sonnet-4-20250514", + "small_model": "anthropic/claude-3-5-haiku-20241022", + "keybinds": { + "leader": ",", + "submit": "", + "abort": "" + }, + "lsp": { + "disabled": false + }, + "mcp": {}, + "provider": {}, + "agent": {} +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:17-23` + +--- + +### PATCH /config + +Update configuration. + +**Request Body:** +```json +{ + "model": "anthropic/claude-opus-4-20250514", + "small_model": "anthropic/claude-3-5-haiku-20241022" +} +``` + +**Response:** Updated config object + +**File Reference:** `go-opencode/internal/server/handlers_config.go:25-42` + +--- + +### GET /config/providers + +Get available providers with models. + +**Response:** +```json +{ + "providers": [ + { + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "npm": "@ai-sdk/anthropic", + "models": { + "claude-sonnet-4-20250514": { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "release_date": "2025-05-14", + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + } + }, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "options": {} + } + } + } + ], + "default": { + "anthropic": "claude-sonnet-4-20250514" + } +} +``` + +**ProviderModel Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Model ID | +| `name` | string | Display name | +| `release_date` | string | Release date (YYYY-MM-DD) | +| `capabilities` | object | Feature support flags | +| `cost.input` | number | $/M input tokens | +| `cost.output` | number | $/M output tokens | +| `cost.cache_read` | number | $/M cache read tokens | +| `cost.cache_write` | number | $/M cache write tokens | +| `limit.context` | number | Max context length | +| `limit.output` | number | Max output tokens | + +**File Reference:** `go-opencode/internal/server/handlers_config.go:211-229` + +--- + +## Provider Endpoints + +### GET /provider + +Get all providers with connection status. + +**Response:** +```json +{ + "all": [ProviderInfo], + "default": { + "anthropic": "claude-sonnet-4-20250514" + }, + "connected": ["anthropic"] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `all` | ProviderInfo[] | All available providers | +| `default` | object | Default model per provider | +| `connected` | string[] | Provider IDs with API keys set | + +**File Reference:** `go-opencode/internal/server/handlers_config.go:238-269` + +--- + +### GET /provider/auth + +Get authentication methods for providers. + +**Response:** +```json +{ + "anthropic": [ + {"type": "api", "label": "Manually enter API Key"} + ], + "openai": [ + {"type": "api", "label": "Manually enter API Key"} + ] +} +``` + +**AuthMethod Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | `"oauth"` or `"api"` | +| `label` | string | Display label | + +**File Reference:** `go-opencode/internal/server/handlers_config.go:282-296` + +--- + +### PUT /auth/{providerID} + +Set API key for a provider. + +**Request Body:** +```json +{ + "apiKey": "sk-..." +} +``` + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:309-330` + +--- + +## File Endpoints + +### GET /file + +List files in a directory. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `path` | string | Directory path (uses context if omitted) | + +**Response:** +```json +{ + "files": [ + { + "name": "file.go", + "isDirectory": false, + "size": 1234 + } + ] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_file.go:24-51` + +--- + +### GET /file/content + +Read file contents. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `path` | string | Yes | File path | +| `offset` | number | No | Line offset | +| `limit` | number | No | Max lines (default 2000) | + +**Response:** +```json +{ + "content": "file contents...", + "lines": 150, + "truncated": false +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_file.go:53-94` + +--- + +### GET /file/status + +Get git status. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `directory` | string | Directory to check | + +**Response:** +```json +{ + "branch": "main", + "staged": ["file1.go"], + "unstaged": ["file2.go"], + "untracked": ["file3.go"] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_file.go:96-137` + +--- + +### GET /vcs + +Get VCS (git) branch info. + +**Response:** +```json +{ + "branch": "main" +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_file.go:262-279` + +--- + +## Search Endpoints + +### GET /find + +Search text in files using ripgrep. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `pattern` | string | Yes | Search regex pattern | +| `path` | string | No | Directory to search | +| `include` | string | No | Glob pattern filter | + +**Response:** +```json +{ + "matches": [ + { + "file": "/path/to/file.go", + "line": 42, + "content": "matching line content" + } + ], + "count": 1, + "truncated": false +} +``` + +**Note:** Results limited to 100 matches. + +**File Reference:** `go-opencode/internal/server/handlers_file.go:139-208` + +--- + +### GET /find/file + +Search for files by pattern. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `pattern` | string | Yes | Glob pattern | +| `path` | string | No | Directory to search | + +**Response:** +```json +{ + "files": ["/path/to/file.go"], + "count": 1 +} +``` + +**Note:** Results limited to 100 files. + +**File Reference:** `go-opencode/internal/server/handlers_file.go:210-247` + +--- + +### GET /find/symbol + +Search for code symbols via LSP. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | string | Yes | Symbol search query | + +**Response:** +```json +[ + { + "name": "NewServer", + "kind": 12, + "containerName": "server", + "location": { + "uri": "file:///path/to/server.go", + "range": { + "start": {"line": 66, "character": 5}, + "end": {"line": 66, "character": 14} + } + } + } +] +``` + +**Symbol Kind Values:** + +| Value | Kind | +|-------|------| +| 5 | Class | +| 6 | Method | +| 10 | Enum | +| 11 | Interface | +| 12 | Function | +| 13 | Variable | +| 14 | Constant | +| 23 | Struct | + +**Note:** Results limited to 10 symbols. + +**File Reference:** `go-opencode/internal/server/handlers_file.go:281-317` + +--- + +## MCP Endpoints + +### GET /mcp + +Get MCP server status. + +**Response:** +```json +{ + "server-name": { + "status": "connected", + "error": null + } +} +``` + +**Status Values:** `"connected"`, `"disabled"`, `"failed"` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:348-368` + +--- + +### POST /mcp + +Add an MCP server. + +**Request Body:** +```json +{ + "name": "server-name", + "type": "stdio", + "command": ["node", "server.js"], + "environment": {"KEY": "value"}, + "timeout": 30000 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Server name | +| `type` | string | Yes | `"stdio"` or `"sse"` | +| `url` | string | No | URL for SSE servers | +| `command` | string[] | No | Command for stdio servers | +| `headers` | object | No | HTTP headers for SSE | +| `environment` | object | No | Environment variables | +| `timeout` | number | No | Connection timeout (ms) | + +**Response:** Server status object + +**File Reference:** `go-opencode/internal/server/handlers_config.go:370-415` + +--- + +### DELETE /mcp/{name} + +Remove an MCP server. + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:417-436` + +--- + +### GET /mcp/tools + +Get all MCP tools. + +**Response:** +```json +[ + { + "name": "tool-name", + "description": "Tool description", + "inputSchema": {...} + } +] +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:438-447` + +--- + +### POST /mcp/tool/{name} + +Execute an MCP tool. + +**Request Body:** Tool arguments (JSON object) + +**Response:** +```json +{ + "result": "tool output" +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:449-475` + +--- + +### GET /mcp/resources + +List MCP resources. + +**Response:** Array of MCP resources + +**File Reference:** `go-opencode/internal/server/handlers_config.go:477-491` + +--- + +### GET /mcp/resource + +Read an MCP resource. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `uri` | string | Yes | Resource URI | + +**Response:** Resource content + +**File Reference:** `go-opencode/internal/server/handlers_config.go:493-513` + +--- + +## Command Endpoints + +### GET /command + +List all commands. + +**Response:** +```json +[ + { + "name": "init", + "description": "create/update AGENTS.md", + "template": "Please analyze...", + "agent": "", + "model": "", + "subtask": false + } +] +``` + +**CommandInfo Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Command name (without /) | +| `description` | string | Short description | +| `template` | string | Prompt template with $ARGUMENTS | +| `agent` | string | Agent to use | +| `model` | string | Model override | +| `subtask` | boolean | Run as subtask | + +**File Reference:** `go-opencode/internal/server/handlers_config.go:801-825` + +--- + +### GET /command/{name} + +Get a single command. + +**Response:** CommandInfo object + +**File Reference:** `go-opencode/internal/server/handlers_config.go:970-995` + +--- + +### POST /command/{name} + +Execute a command. + +**Request Body:** +```json +{ + "args": "argument string", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx" +} +``` + +**Response:** Command result object + +**SSE Event:** `command.executed` with: +```json +{ + "name": "command-name", + "sessionID": "ses_xxxx", + "arguments": "argument string", + "messageID": "msg_xxxx" +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:927-968` + +--- + +## TUI Control Endpoints + +### POST /tui/append-prompt + +Append text to the TUI prompt. + +**Request Body:** +```json +{ + "text": "text to append" +} +``` + +**Response:** +```json +{"success": true} +``` + +**SSE Event:** `tui.prompt.append` with `{text: string}` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:14-31` + +--- + +### POST /tui/execute-command + +Execute a TUI command. + +**Request Body:** +```json +{ + "command": "session.new" +} +``` + +**Response:** +```json +{"success": true} +``` + +**SSE Event:** `tui.command.execute` with `{command: string}` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:33-50` + +--- + +### POST /tui/show-toast + +Show a toast notification. + +**Request Body:** +```json +{ + "title": "Optional Title", + "message": "Notification message", + "variant": "info", + "duration": 5000 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | No | Toast title | +| `message` | string | Yes | Toast message | +| `variant` | string | Yes | `"info"`, `"success"`, `"warning"`, `"error"` | +| `duration` | number | No | Display duration (ms) | + +**Response:** +```json +{"success": true} +``` + +**SSE Event:** `tui.toast.show` with full request data + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:52-77` + +--- + +### POST /tui/publish + +Publish a generic TUI event. + +**Request Body:** +```json +{ + "type": "tui.prompt.append", + "properties": { + "text": "appended text" + } +} +``` + +**Supported Types:** +- `tui.prompt.append` +- `tui.command.execute` +- `tui.toast.show` + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:79-121` + +--- + +### POST /tui/open-help + +Open the help dialog. + +**Response:** +```json +{"success": true} +``` + +--- + +### POST /tui/open-sessions + +Open the sessions dialog. + +**Response:** +```json +{"success": true} +``` + +--- + +### POST /tui/open-themes + +Open the themes dialog. + +**Response:** +```json +{"success": true} +``` + +--- + +### POST /tui/open-models + +Open the models dialog. + +**Response:** +```json +{"success": true} +``` + +--- + +### POST /tui/submit-prompt + +Submit the current prompt. + +**Request Body:** +```json +{ + "text": "prompt text" +} +``` + +**Response:** +```json +{"success": true} +``` + +--- + +### POST /tui/clear-prompt + +Clear the prompt. + +**Response:** +```json +{"success": true} +``` + +--- + +### GET /tui/control/next + +Get next pending TUI control request. + +**Response:** +```json +{ + "path": "/some/path", + "body": {...} +} +``` + +Returns empty path if nothing pending. + +--- + +### POST /tui/control/response + +Submit a response to a TUI control request. + +**Request Body:** Response data (any JSON) + +**Response:** +```json +{"success": true} +``` + +--- + +## Client Tools Endpoints + +### POST /client-tools/register + +Register client-provided tools. + +**Request Body:** +```json +{ + "clientID": "client-xxx", + "tools": [ + { + "id": "my-tool", + "description": "Tool description", + "parameters": { + "type": "object", + "properties": {...} + } + } + ] +} +``` + +**Response:** +```json +{ + "registered": ["my-tool"] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:187-221` + +--- + +### DELETE /client-tools/unregister + +Unregister client tools. + +**Request Body:** +```json +{ + "clientID": "client-xxx", + "toolIDs": ["my-tool"] +} +``` + +**Response:** +```json +{ + "success": true, + "unregistered": ["my-tool"] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:223-244` + +--- + +### POST /client-tools/execute + +Execute a client tool. + +**Request Body:** +```json +{ + "toolID": "my-tool", + "requestID": "req-xxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "callID": "call_xxxx", + "input": {...}, + "timeout": 30000 +} +``` + +**Response:** +```json +{ + "status": "success", + "output": "result..." +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:246-291` + +--- + +### POST /client-tools/result + +Submit a tool execution result. + +**Request Body:** +```json +{ + "requestID": "req-xxx", + "status": "success", + "title": "Result Title", + "output": "tool output", + "metadata": {...}, + "error": null +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `requestID` | string | Yes | Original request ID | +| `status` | string | Yes | `"success"` or `"error"` | +| `title` | string | No | Display title | +| `output` | string | No | Tool output | +| `metadata` | object | No | Additional metadata | +| `error` | string | No | Error message (if status=error) | + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:293-327` + +--- + +### GET /client-tools/pending/{clientID} + +Get pending tool requests for a client. + +**Response:** Array of pending execution requests + +--- + +### GET /client-tools/tools/{clientID} + +Get tools registered by a specific client. + +**Response:** Array of tool definitions + +--- + +### GET /client-tools/tools + +Get all registered client tools. + +**Response:** Array of all tool definitions + +--- + +## Utility Endpoints + +### GET /agent + +List available agents. + +**Response:** +```json +[ + { + "name": "build", + "description": "", + "mode": "primary", + "builtIn": true, + "prompt": "", + "tools": {}, + "options": {}, + "permission": { + "edit": "allow", + "bash": {"*": "allow"}, + "webfetch": "allow", + "external_directory": "ask", + "doom_loop": "ask" + }, + "temperature": 0, + "topP": 0, + "model": null, + "color": "" + } +] +``` + +**AgentInfo Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Agent identifier | +| `description` | string | Agent description | +| `mode` | string | `"primary"` or `"subagent"` | +| `builtIn` | boolean | Built-in vs custom | +| `prompt` | string | Custom system prompt | +| `tools` | object | Tool enable/disable map | +| `options` | object | Agent options | +| `permission` | object | Permission settings | +| `temperature` | number | Temperature override | +| `topP` | number | Top-p override | +| `model` | ModelRef? | Model override | +| `color` | string | UI color | + +**Permission Fields:** + +| Field | Type | Values | +|-------|------|--------| +| `edit` | string | `"allow"`, `"deny"`, `"ask"` | +| `bash` | object | Pattern → permission map | +| `webfetch` | string | `"allow"`, `"deny"`, `"ask"` | +| `external_directory` | string | `"allow"`, `"deny"`, `"ask"` | +| `doom_loop` | string | `"allow"`, `"deny"`, `"ask"` | + +**File Reference:** `go-opencode/internal/server/handlers_config.go:547-624` + +--- + +### GET /lsp + +Get LSP server status. + +**Response:** +```json +{ + "enabled": true, + "servers": [] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:332-339` + +--- + +### GET /formatter + +Get formatter status. + +**Response:** +```json +{ + "enabled": true, + "formatters": [...] +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:743-752` + +--- + +### POST /formatter/format + +Format a file. + +**Request Body:** +```json +{ + "path": "/path/to/file.go" +} +``` + +Or for multiple files: +```json +{ + "paths": ["/path/to/file1.go", "/path/to/file2.go"] +} +``` + +**Response:** Format result(s) + +**File Reference:** `go-opencode/internal/server/handlers_config.go:754-788` + +--- + +### GET /path + +Get the current working directory. + +**Response:** +```json +{ + "directory": "/path/to/project" +} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:997-1002` + +--- + +### POST /log + +Write to server log. + +**Request Body:** Log message (any format) + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:1004-1008` + +--- + +### POST /instance/dispose + +Dispose of server instance resources. + +**Response:** +```json +{"success": true} +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:1010-1014` + +--- + +### GET /experimental/tool/ids + +Get list of tool IDs. + +**Response:** +```json +["read", "write", "edit", "bash", "glob", "grep", ...] +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:1016-1024` + +--- + +### GET /experimental/tool + +Get full tool definitions. + +**Response:** +```json +[ + { + "name": "read", + "description": "Read file contents", + "parameters": {...} + } +] +``` + +**File Reference:** `go-opencode/internal/server/handlers_config.go:1026-1038` + +--- + +### GET /doc + +Get OpenAPI specification. + +**Response:** OpenAPI 3.0.0 spec document + +**File Reference:** `go-opencode/internal/server/handlers_tui.go:329-343` + +--- + +### GET /project + +List projects. + +**Response:** Array of Project objects + +--- + +### GET /project/current + +Get current project. + +**Response:** Project object + +--- + +## Part Types Reference + +Parts are the content blocks within messages. + +### TextPart + +```json +{ + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "text", + "text": "Text content...", + "time": { + "start": 1764964063910, + "end": 1764964065196 + } +} +``` + +### ToolPart + +```json +{ + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "tool", + "callID": "toolu_xxxx", + "tool": "read", + "state": { + "status": "completed", + "input": {"filePath": "/path/to/file"}, + "raw": "", + "output": "file contents...", + "error": null, + "title": "go-opencode/internal/server.go", + "metadata": { + "lineCount": 150, + "truncated": false + }, + "time": { + "start": 1764964069518, + "end": 1764964072550 + } + } +} +``` + +**Tool Status Values:** `"pending"`, `"running"`, `"completed"`, `"error"` + +### FilePart + +```json +{ + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "file", + "filename": "image.png", + "mime": "image/png", + "url": "data:image/png;base64,..." +} +``` + +### StepStartPart + +```json +{ + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "step-start", + "snapshot": "git-hash" +} +``` + +### StepFinishPart + +```json +{ + "id": "prt_xxxx", + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "git-hash", + "cost": 0.0, + "tokens": { + "input": 5, + "output": 224, + "reasoning": 0, + "cache": { + "read": 13510, + "write": 248 + } + } +} +``` + +--- + +## Permission Event Reference + +### permission.updated + +Sent when a tool requires user permission. + +```json +{ + "type": "permission.updated", + "properties": { + "id": "per_xxxx", + "type": "external_directory", + "pattern": ["/path/to/dir", "/path/to/dir/*"], + "sessionID": "ses_xxxx", + "messageID": "msg_xxxx", + "callID": "toolu_xxxx", + "title": "Access file outside working directory", + "metadata": { + "filepath": "/path/to/file.go", + "parentDir": "/path/to/dir" + }, + "time": { + "created": 1764964069517 + } + } +} +``` + +**Permission Types:** +- `external_directory` - Access outside working directory +- `bash` - Execute shell command +- `edit` - Edit file + +--- + +## Version History + +- **1.0.0** (2025-12-05) - Initial comprehensive endpoint documentation + +--- + +## References + +- **Event Protocol:** See `docs/tui-event-specification.md` +- **TUI Protocol:** See `docs/tui-protocol-specification.md` +- **TypeScript Reference:** `packages/opencode/src/server/` +- **Go Implementation:** `go-opencode/internal/server/` diff --git a/docs/tui-event-specification.md b/docs/tui-event-specification.md new file mode 100644 index 00000000000..b708861296a --- /dev/null +++ b/docs/tui-event-specification.md @@ -0,0 +1,1089 @@ +# OpenCode TUI Event Protocol Specification + +**Version:** 2.0.0 +**Last Updated:** 2025-12-05 + +## Table of Contents + +- [Overview](#overview) +- [Comparison with AI SDK UI Message Stream Protocol](#comparison-with-ai-sdk-ui-message-stream-protocol) +- [Transport Layer](#transport-layer) +- [Event Envelope Format](#event-envelope-format) +- [Event Categories](#event-categories) +- [Complete Event Reference](#complete-event-reference) +- [Event Temporal Sequences](#event-temporal-sequences) +- [Metadata Usage](#metadata-usage) +- [Validation Against Existing Documentation](#validation-against-existing-documentation) + +--- + +## Overview + +The OpenCode TUI Event Protocol defines how the server communicates real-time state changes to TUI clients via Server-Sent Events (SSE). Unlike request-response patterns, events flow unidirectionally from server to client, enabling real-time UI updates during AI interactions. + +### Key Characteristics + +| Characteristic | OpenCode TUI Protocol | AI SDK UI Message Stream | +|---------------|----------------------|--------------------------| +| **Transport** | SSE (Server-Sent Events) | SSE (Server-Sent Events) | +| **Envelope** | `{type, properties}` | `{type, ...fields}` | +| **Scope** | Session-centric (stateful) | Message-centric (stateless) | +| **Event Count** | 31+ event types | 24 event types | +| **Streaming** | Accumulated + Delta | Delta only | +| **Tool State** | Full state machine | Input/Output streaming | + +### File References + +- **Event Type Constants**: `go-opencode/internal/event/bus.go:40-89` +- **Event Data Structures**: `go-opencode/internal/event/types.go` +- **SSE Handler**: `go-opencode/internal/server/sse.go` +- **TypeScript Reference**: `packages/opencode/src/bus/index.ts` + +--- + +## Comparison with AI SDK UI Message Stream Protocol + +### Architectural Differences + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AI SDK UI Message Stream │ +│ │ +│ Request ──► [start] ──► [text-start] ──► [text-delta]* ──► [text-end] │ +│ ──► [tool-input-start] ──► [tool-output] ──► [finish] │ +│ │ +│ • Stateless: Each message is independent │ +│ • Fine-grained: Separate start/delta/end for each content type │ +│ • Message-scoped: Events tied to single message generation │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ OpenCode TUI Protocol │ +│ │ +│ [session.status:busy] ──► [message.created] ──► [message.part.updated]* │ +│ ──► [session.diff] ──► [session.status:idle] │ +│ │ +│ • Stateful: Events update persistent session state │ +│ • Coarse-grained: Part updates carry full state + optional delta │ +│ • Session-scoped: Events include sessionID for multi-session support │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Event Type Mapping + +| Purpose | AI SDK Event | OpenCode Event | +|---------|-------------|----------------| +| Message start | `start` | `message.created` | +| Message end | `finish` | `message.updated` (with finish reason) | +| Text streaming | `text-start`, `text-delta`, `text-end` | `message.part.updated` (type=text, delta field) | +| Reasoning | `reasoning-start`, `reasoning-delta`, `reasoning-end` | `message.part.updated` (type=reasoning) | +| Tool input | `tool-input-start`, `tool-input-delta`, `tool-input-available` | `message.part.updated` (type=tool, state.status) | +| Tool output | `tool-output-available` | `message.part.updated` (type=tool, state.output) | +| Error | `error` | `session.error` or tool state.error | +| File reference | `file` | `message.part.updated` (type=file) | +| Step boundaries | `start-step`, `finish-step` | `message.part.updated` (type=step-start/step-finish) | +| Stream end | `[DONE]` | `session.idle` | + +### Key Differences in Detail + +#### 1. Delta Handling + +**AI SDK**: Sends only the delta (incremental text) +```json +{"type": "text-delta", "delta": "Hello"} +{"type": "text-delta", "delta": " world"} +``` + +**OpenCode**: Sends accumulated state + optional delta +```json +{ + "type": "message.part.updated", + "properties": { + "part": {"type": "text", "text": "Hello"}, + "delta": "Hello" + } +} +{ + "type": "message.part.updated", + "properties": { + "part": {"type": "text", "text": "Hello world"}, + "delta": " world" + } +} +``` + +**Rationale**: OpenCode's approach allows late-joining clients to get current state without replay. + +#### 2. Tool State Machine + +**AI SDK**: Streaming tool input then output +```json +{"type": "tool-input-start", "toolCallId": "call_1", "toolName": "read"} +{"type": "tool-input-delta", "toolCallId": "call_1", "delta": "{\"path\":"} +{"type": "tool-input-available", "toolCallId": "call_1", "input": {"path": "/file.txt"}} +{"type": "tool-output-available", "toolCallId": "call_1", "output": "file contents"} +``` + +**OpenCode**: Full state machine in single event type +```json +{"type": "message.part.updated", "properties": {"part": {"type": "tool", "state": {"status": "pending"}}}} +{"type": "message.part.updated", "properties": {"part": {"type": "tool", "state": {"status": "running", "input": {...}}}}} +{"type": "message.part.updated", "properties": {"part": {"type": "tool", "state": {"status": "completed", "output": "..."}}}} +``` + +#### 3. Session vs Message Scope + +**AI SDK**: Events scoped to single message generation +- No cross-message state +- No persistent session concept + +**OpenCode**: Events scoped to session with message context +- `sessionID` in every event +- Session lifecycle events (created, deleted, idle) +- Diff tracking across messages + +--- + +## Transport Layer + +### SSE Connection + +**Endpoint**: `GET /event` + +**Headers**: +```http +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +X-Accel-Buffering: no +``` + +**Query Parameters**: +- `directory` (optional): Working directory path +- `sessionID` (optional): Filter events to specific session + +### Wire Format + +``` +event: message +data: {"type":"session.created","properties":{"info":{...}}} + +event: message +data: {"type":"message.part.updated","properties":{"part":{...},"delta":"text"}} + +: heartbeat + +``` + +**File Reference**: `go-opencode/internal/server/sse.go:45-78` + +--- + +## Event Envelope Format + +All events follow the SDK-compatible envelope format: + +```typescript +interface Event { + type: string; // Event type identifier (e.g., "session.created") + properties: object; // Event-specific data payload +} +``` + +**File Reference**: `go-opencode/internal/server/sse.go:20-24` + +--- + +## Event Categories + +### Session Events (8 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `session.created` | New session created | POST /session | +| `session.updated` | Session metadata changed | PATCH /session, title generation | +| `session.deleted` | Session removed | DELETE /session | +| `session.status` | Processing state changed | Message processing start/end | +| `session.idle` | Session became idle | Message processing complete | +| `session.diff` | File changes tracked | After tool execution | +| `session.error` | Error occurred | Processing failure | +| `session.compacted` | History compressed | Manual/auto compaction | + +### Message Events (5 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `message.created` | New message added | User/assistant message created | +| `message.updated` | Message changed | Status change, completion | +| `message.removed` | Message deleted | Message removal | +| `message.part.updated` | Part content changed | Streaming, tool updates | +| `message.part.removed` | Part deleted | Part removal | + +### Permission Events (2 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `permission.updated` | Permission request created | Tool needs approval | +| `permission.replied` | User responded | User grants/denies | + +### TUI Events (3 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `tui.prompt.append` | Append text to prompt | API call | +| `tui.command.execute` | Execute TUI command | API call | +| `tui.toast.show` | Show notification | API call | + +### VCS Events (1 type) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `vcs.branch.updated` | Git branch changed | .git/HEAD file change | + +### PTY Events (4 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `pty.created` | Terminal session created | POST /pty | +| `pty.updated` | Terminal updated | Resize, title change | +| `pty.exited` | Process exited | Terminal process exit | +| `pty.deleted` | Terminal removed | DELETE /pty | + +### Command Events (1 type) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `command.executed` | Slash command run | POST /command/:name | + +### Client Tool Events (6 types) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `client-tool.request` | Execution requested | AI calls client tool | +| `client-tool.registered` | Tools registered | POST /client-tools/register | +| `client-tool.unregistered` | Tools removed | DELETE /client-tools/unregister | +| `client-tool.executing` | Execution started | Tool execution begins | +| `client-tool.completed` | Execution succeeded | Tool returns success | +| `client-tool.failed` | Execution failed | Tool returns error | + +### File Events (1 type) + +| Event Type | Description | When Fired | +|-----------|-------------|------------| +| `file.edited` | File was modified | Write/Edit tool execution | + +--- + +## Complete Event Reference + +### session.created + +Fired when a new session is created. + +```typescript +{ + type: "session.created", + properties: { + info: { + id: string, // Session ID (ULID format) + projectID: string, // Project identifier + directory: string, // Working directory path + parentID?: string, // Parent session for forks + title: string, // Session title + version: string, // Schema version + summary: { + title?: string, // AI-generated title + body?: string, // Summary text + diffs?: FileDiff[] // File changes + }, + share?: { + url: string // Sharing URL + }, + time: { + created: number, // Unix timestamp (ms) + updated?: number // Last update timestamp + } + } + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:6-9` + +--- + +### session.updated + +Fired when session metadata changes. + +```typescript +{ + type: "session.updated", + properties: { + info: Session // Full session object (same as session.created) + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:11-15` + +--- + +### session.status + +Fired when session processing state changes. + +```typescript +{ + type: "session.status", + properties: { + sessionID: string, + status: { + type: "busy" | "idle" // Current processing state + } + } +} +``` + +**Temporal Position**: +- `busy`: First event when message processing starts +- `idle`: Fired after `session.idle` at processing completion + +**File Reference**: `go-opencode/internal/event/types.go:28-38` + +--- + +### session.diff + +Fired when file changes are recorded. + +```typescript +{ + type: "session.diff", + properties: { + sessionID: string, + diff: Array<{ + file: string, // File path + additions: number, // Lines added + deletions: number, // Lines removed + before?: string, // Original content + after?: string // New content + }> + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:40-45` + +--- + +### session.error + +Fired when an error occurs during processing. + +```typescript +{ + type: "session.error", + properties: { + sessionID?: string, + error?: { + name: string, // Error type + data: { + message: string, // Error message + providerID?: string // Provider that caused error + } + } + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:47-51` + +--- + +### session.idle + +Fired when session becomes idle after processing. + +```typescript +{ + type: "session.idle", + properties: { + sessionID: string + } +} +``` + +**Temporal Position**: Final event in message processing sequence. + +**File Reference**: `go-opencode/internal/event/types.go:24-26` + +--- + +### message.created + +Fired when a new message is added to the session. + +```typescript +{ + type: "message.created", + properties: { + info: { + id: string, // Message ID + sessionID: string, // Parent session + role: "user" | "assistant", // Message role + time: { + created: number, + updated?: number + }, + // User message fields + agent?: string, // Agent name + model?: { + providerID: string, + modelID: string + }, + // Assistant message fields + parentID?: string, // Parent user message + modelID?: string, + providerID?: string, + mode?: string, // Execution mode + finish?: string, // Finish reason + cost?: number, // Cost in USD + tokens?: { + input: number, + output: number, + reasoning: number, + cache: { + read: number, + write: number + } + }, + error?: MessageError + } + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:58-62`, `go-opencode/pkg/types/message.go` + +--- + +### message.updated + +Fired when message metadata changes. + +```typescript +{ + type: "message.updated", + properties: { + info: Message // Full message object + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:64-68` + +--- + +### message.part.updated + +**The most frequently fired event during streaming.** Contains part state and optional delta. + +```typescript +{ + type: "message.part.updated", + properties: { + part: Part, // Full part state (see Part Types below) + delta?: string // Incremental text (for streaming only) + } +} +``` + +#### Part Types + +**TextPart**: +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "text", + text: string, // Accumulated text + time?: { start?: number, end?: number }, + metadata?: Record +} +``` + +**ReasoningPart**: +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "reasoning", + text: string, // Reasoning content + time?: { start?: number, end?: number } +} +``` + +**ToolPart** (most complex): +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "tool", + callID: string, // Tool call identifier + tool: string, // Tool name + state: { + status: "pending" | "running" | "completed" | "error", + input: Record, // Tool parameters + raw?: string, // Raw input string (for streaming) + output?: string, // Tool output + error?: string, // Error message + title?: string, // Display title + metadata?: Record, // Tool-specific data + time?: { + start: number, + end?: number, + compacted?: number + }, + attachments?: FilePart[] + }, + metadata?: Record +} +``` + +**FilePart**: +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "file", + filename?: string, + mime: string, + url: string +} +``` + +**StepStartPart**: +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "step-start", + snapshot?: string // State snapshot +} +``` + +**StepFinishPart**: +```typescript +{ + id: string, + sessionID: string, + messageID: string, + type: "step-finish", + reason: string, // Finish reason + snapshot?: string, + cost: number, + tokens?: TokenUsage +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:76-91`, `go-opencode/pkg/types/parts.go` + +--- + +### permission.updated + +Fired when a permission request is created. + +```typescript +{ + type: "permission.updated", + properties: { + id: string, // Permission request ID + sessionID: string, + permissionType: "bash" | "edit" | "external_directory", + pattern: string[], // What's being requested + title: string // Display title + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:98-106` + +--- + +### permission.replied + +Fired when user responds to permission request. + +```typescript +{ + type: "permission.replied", + properties: { + permissionID: string, + sessionID: string, + response: "once" | "always" | "reject" + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:111-116` + +--- + +### tui.prompt.append + +Fired to append text to the TUI prompt. + +```typescript +{ + type: "tui.prompt.append", + properties: { + text: string // Text to append + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:156-159` + +--- + +### tui.command.execute + +Fired to execute a TUI command. + +```typescript +{ + type: "tui.command.execute", + properties: { + command: string // Command name (e.g., "session.new") + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:161-164` + +--- + +### tui.toast.show + +Fired to display a toast notification. + +```typescript +{ + type: "tui.toast.show", + properties: { + title?: string, // Optional title + message: string, // Notification message + variant: "info" | "success" | "warning" | "error", + duration?: number // Display duration (ms) + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:166-172` + +--- + +### vcs.branch.updated + +Fired when git branch changes. + +```typescript +{ + type: "vcs.branch.updated", + properties: { + branch?: string // New branch name + } +} +``` + +**Trigger**: fsnotify watch on `.git/HEAD` file. + +**File Reference**: `go-opencode/internal/event/types.go:176-179`, `go-opencode/internal/vcs/watcher.go` + +--- + +### pty.created / pty.updated + +Fired when PTY session is created or updated. + +```typescript +{ + type: "pty.created" | "pty.updated", + properties: { + info: { + id: string, + title: string, + command: string, + args: string[], + cwd: string, + status: "running" | "exited", + pid: number + } + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:183-202` + +--- + +### pty.exited + +Fired when PTY process exits. + +```typescript +{ + type: "pty.exited", + properties: { + id: string, + exitCode: number + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:204-208` + +--- + +### pty.deleted + +Fired when PTY session is removed. + +```typescript +{ + type: "pty.deleted", + properties: { + id: string + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:210-213` + +--- + +### command.executed + +Fired when a slash command is executed. + +```typescript +{ + type: "command.executed", + properties: { + name: string, // Command name + sessionID: string, + arguments: string, // Command arguments + messageID: string + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:217-223` + +--- + +### client-tool.* Events + +See the [Client Tools Protocol](#client-tools-protocol) section in the main specification. + +**File Reference**: `go-opencode/internal/event/types.go:125-152` + +--- + +### file.edited + +Fired when a file is modified. + +```typescript +{ + type: "file.edited", + properties: { + file: string // File path + } +} +``` + +**File Reference**: `go-opencode/internal/event/types.go:93-96` + +--- + +## Event Temporal Sequences + +### Session Creation Flow + +``` +POST /session + │ + ▼ +┌─────────────────────┐ +│ session.created │ ◄── Single event +└─────────────────────┘ +``` + +**Simplest flow**: Single event upon successful session creation. + +--- + +### Message Sending Flow (Complete) + +``` +POST /session/{id}/message + │ + ▼ +┌─────────────────────┐ +│ message.updated │ ◄── User message stored (status: completed) +│ (user message) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ message.part.updated│ ◄── User text part +│ (type: text) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ session.status │ ◄── status.type = "busy" +│ (busy) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ session.updated │ ◄── Session state updated +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ session.diff │ ◄── Empty diffs at start +│ (initial) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ message.created │ ◄── Assistant message created +│ (assistant) │ +└──────────┬──────────┘ + │ + ▼ + ╔═══════════════════════════════════════╗ + ║ STREAMING PHASE (repeated) ║ + ╠═══════════════════════════════════════╣ + ║ message.part.updated (step-start) ║ + ║ message.part.updated (text, delta) *N ║ + ║ message.part.updated (reasoning) *N ║ + ║ message.part.updated (tool) *N ║ + ║ message.part.updated (step-finish) ║ + ╚═══════════════════════════════════════╝ + │ + ▼ +┌─────────────────────┐ +│ message.updated │ ◄── Final message state (finish reason) +│ (complete) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ session.status │ ◄── status.type = "idle" +│ (idle) │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ session.idle │ ◄── Final event +└─────────────────────┘ +``` + +--- + +### Tool Execution Flow (within streaming) + +``` +LLM returns tool_use + │ + ▼ +┌─────────────────────────────────────┐ +│ message.part.updated │ ◄── status: "pending" +│ type: "tool" │ +│ state.status: "pending" │ +│ state.input: {} (accumulating) │ +└──────────────────┬──────────────────┘ + │ (argument streaming) + ▼ +┌─────────────────────────────────────┐ +│ message.part.updated │ ◄── status: "running" +│ state.status: "running" │ +│ state.input: {complete} │ +└──────────────────┬──────────────────┘ + │ (execution) + ▼ +┌─────────────────────────────────────┐ +│ message.part.updated │ ◄── Metadata updates (optional) +│ state.metadata: {...} │ +└──────────────────┬──────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ +│ SUCCESS │ │ FAILURE │ +├───────────────────┤ ├───────────────────┤ +│ message.part. │ │ message.part. │ +│ updated │ │ updated │ +│ status: completed │ │ status: error │ +│ output: "..." │ │ error: "..." │ +└───────────────────┘ └───────────────────┘ + │ │ + └──────────┬──────────┘ + ▼ +┌─────────────────────────────────────┐ +│ session.diff │ ◄── If file was edited +│ diff: [{file, additions, ...}] │ +└─────────────────────────────────────┘ +``` + +--- + +### Permission Request Flow + +``` +Tool requires permission + │ + ▼ +┌─────────────────────────────────────┐ +│ permission.updated │ ◄── Request created +│ id: "perm_xxx" │ +│ permissionType: "bash" │ +│ pattern: ["rm -rf *"] │ +│ title: "Delete all files" │ +└──────────────────┬──────────────────┘ + │ + ╔══════════════╧══════════════╗ + ║ BLOCKING: Waits for user ║ + ║ response via HTTP POST ║ + ╚══════════════╤══════════════╝ + │ + ▼ +┌─────────────────────────────────────┐ +│ permission.replied │ ◄── User response +│ permissionID: "perm_xxx" │ +│ response: "once" | "always" | │ +│ "reject" │ +└──────────────────┬──────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ + [response: once/always] [response: reject] + │ │ + ▼ ▼ + Tool executes Tool fails with + RejectedError +``` + +--- + +## Metadata Usage + +### Tool Part Metadata + +The `metadata` field in ToolPart is used for tool-specific data that doesn't fit the standard schema: + +| Tool | Metadata Fields | Purpose | +|------|----------------|---------| +| `Read` | `lineCount`, `truncated` | File reading stats | +| `Write` | `created`, `backup` | File creation info | +| `Edit` | `matches`, `replaced` | Edit operation stats | +| `Bash` | `exitCode`, `signal` | Process exit info | +| `Glob` | `matchCount` | Search results count | +| `Grep` | `matchCount`, `files` | Search results | +| `Task` | `subagentType`, `agentId` | Subagent info | + +**Example**: +```json +{ + "type": "message.part.updated", + "properties": { + "part": { + "type": "tool", + "tool": "Read", + "state": { + "status": "completed", + "output": "file contents...", + "metadata": { + "lineCount": 150, + "truncated": true, + "truncatedAt": 100 + } + } + } + } +} +``` + +### Text Part Metadata + +Used for rendering hints: + +| Field | Purpose | +|-------|---------| +| `language` | Code block language for syntax highlighting | +| `title` | Code block title | + +--- + +## Validation Against Existing Documentation + +### Comparison with `docs/tui-protocol-specification.md` + +| Aspect | Existing Doc (v1.1.0) | This Spec (v2.0.0) | Status | +|--------|----------------------|-------------------|--------| +| Event envelope | `{type, properties}` | `{type, properties}` | **Correct** | +| session.created | `{sessionID}` | `{info: Session}` | **Updated** - SDK format | +| session.updated | `{sessionID, title}` | `{info: Session}` | **Updated** - Full object | +| session.status | `status: "pending"\|"running"\|...` | `status.type: "busy"\|"idle"` | **Updated** - Simpler states | +| message.part.updated | `{part, delta}` | `{part, delta}` | **Correct** | +| message.updated | `{sessionID, messageID, status}` | `{info: Message}` | **Updated** - Full object | +| permission.updated | `{sessionID, permissionID, tool, status}` | `{id, sessionID, permissionType, pattern, title}` | **Updated** - SDK format | +| permission.replied | `{response: "allow"\|"deny"\|...}` | `{response: "once"\|"always"\|"reject"}` | **Updated** - Correct values | +| file.edited | `{sessionID, messageID, path}` | `{file}` | **Updated** - Simpler format | +| VCS events | Not documented | `vcs.branch.updated` | **Added** | +| PTY events | Not documented | `pty.*` | **Added** | +| Command events | Partial | `command.executed` | **Expanded** | + +### Breaking Changes from v1.1.0 + +1. **Session events now use `info` wrapper** for SDK compatibility +2. **Permission events renamed**: `permissionID` field name changes +3. **Status values simplified**: "pending/running/completed/error" → "busy/idle" +4. **File events simplified**: Removed session/message context + +### Additions in v2.0.0 + +1. VCS events (`vcs.branch.updated`) +2. PTY events (`pty.created`, `pty.updated`, `pty.exited`, `pty.deleted`) +3. Command events (`command.executed`) +4. Complete temporal sequence documentation +5. AI SDK comparison + +--- + +## Version History + +- **2.0.0** (2025-12-05) - Complete rewrite with temporal sequences, AI SDK comparison, metadata documentation +- **1.1.0** (2025-11-25) - Added Client Tools Protocol +- **1.0.0** (2025-11-24) - Initial specification + +--- + +## References + +- **AI SDK UI Message Stream Protocol**: https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol +- **Server-Sent Events Specification**: https://html.spec.whatwg.org/multipage/server-sent-events.html +- **OpenCode Repository**: https://github.com/sst/opencode +- **go-opencode Implementation**: `go-opencode/internal/event/` +- **TypeScript Implementation**: `packages/opencode/src/bus/` diff --git a/go-opencode/go.mod b/go-opencode/go.mod index 68d4120ddf7..727e9eb3b1f 100644 --- a/go-opencode/go.mod +++ b/go-opencode/go.mod @@ -67,6 +67,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/eino-contrib/jsonschema v1.0.2 // indirect github.com/evanphx/json-patch v0.5.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/go-opencode/go.sum b/go-opencode/go.sum index 9bd3a3d5a63..068b140ed9c 100644 --- a/go-opencode/go.sum +++ b/go-opencode/go.sum @@ -91,6 +91,8 @@ github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMi github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +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/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= diff --git a/go-opencode/internal/event/bus.go b/go-opencode/internal/event/bus.go index 61e821cd0a1..58d5ef9eb9e 100644 --- a/go-opencode/internal/event/bus.go +++ b/go-opencode/internal/event/bus.go @@ -55,6 +55,23 @@ const ( PermissionReplied EventType = "permission.replied" // SDK compatible (was permission.resolved) TodoUpdated EventType = "todo.updated" + // TUI Events + TuiPromptAppend EventType = "tui.prompt.append" + TuiCommandExecute EventType = "tui.command.execute" + TuiToastShow EventType = "tui.toast.show" + + // VCS Events + VcsBranchUpdated EventType = "vcs.branch.updated" + + // PTY Events + PtyCreated EventType = "pty.created" + PtyUpdated EventType = "pty.updated" + PtyExited EventType = "pty.exited" + PtyDeleted EventType = "pty.deleted" + + // Command Events + CommandExecuted EventType = "command.executed" + // Client Tool Events ClientToolRequest EventType = "client-tool.request" ClientToolRegistered EventType = "client-tool.registered" diff --git a/go-opencode/internal/event/types.go b/go-opencode/internal/event/types.go index 3cc4d52e140..c417374c26a 100644 --- a/go-opencode/internal/event/types.go +++ b/go-opencode/internal/event/types.go @@ -150,3 +150,74 @@ type ClientToolStatusData struct { Error string `json:"error,omitempty"` Success bool `json:"success,omitempty"` } + +// TUI Events + +// TuiPromptAppendData is the data for tui.prompt.append events. +type TuiPromptAppendData struct { + Text string `json:"text"` +} + +// TuiCommandExecuteData is the data for tui.command.execute events. +type TuiCommandExecuteData struct { + Command string `json:"command"` +} + +// TuiToastShowData is the data for tui.toast.show events. +type TuiToastShowData struct { + Title string `json:"title,omitempty"` + Message string `json:"message"` + Variant string `json:"variant"` // "info" | "success" | "warning" | "error" + Duration int `json:"duration,omitempty"` +} + +// VCS Events + +// VcsBranchUpdatedData is the data for vcs.branch.updated events. +type VcsBranchUpdatedData struct { + Branch string `json:"branch,omitempty"` +} + +// PTY Events + +// PtyInfo represents a PTY session. +type PtyInfo struct { + ID string `json:"id"` + Title string `json:"title"` + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + Status string `json:"status"` // "running" | "exited" + Pid int `json:"pid"` +} + +// PtyCreatedData is the data for pty.created events. +type PtyCreatedData struct { + Info PtyInfo `json:"info"` +} + +// PtyUpdatedData is the data for pty.updated events. +type PtyUpdatedData struct { + Info PtyInfo `json:"info"` +} + +// PtyExitedData is the data for pty.exited events. +type PtyExitedData struct { + ID string `json:"id"` + ExitCode int `json:"exitCode"` +} + +// PtyDeletedData is the data for pty.deleted events. +type PtyDeletedData struct { + ID string `json:"id"` +} + +// Command Events + +// CommandExecutedData is the data for command.executed events. +type CommandExecutedData struct { + Name string `json:"name"` + SessionID string `json:"sessionID"` + Arguments string `json:"arguments"` + MessageID string `json:"messageID"` +} diff --git a/go-opencode/internal/server/handlers_config.go b/go-opencode/internal/server/handlers_config.go index e5f96c3497b..2883db8a919 100644 --- a/go-opencode/internal/server/handlers_config.go +++ b/go-opencode/internal/server/handlers_config.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/opencode-ai/opencode/internal/event" "github.com/opencode-ai/opencode/internal/command" "github.com/opencode-ai/opencode/internal/mcp" @@ -74,12 +75,18 @@ type ModalityCapabilities struct { PDF bool `json:"pdf"` } +// ModelCostCache represents cache pricing (nested structure for TS compatibility). +type ModelCostCache struct { + Read float64 `json:"read"` + Write float64 `json:"write"` +} + // ModelCost represents model pricing. +// SDK compatible: uses nested cache object to match TypeScript structure. type ModelCost struct { - Input float64 `json:"input"` - Output float64 `json:"output"` - CacheRead float64 `json:"cache_read,omitempty"` - CacheWrite float64 `json:"cache_write,omitempty"` + Input float64 `json:"input"` + Output float64 `json:"output"` + Cache ModelCostCache `json:"cache"` // Nested object, not flat fields } // ModelLimit represents model limits. @@ -126,7 +133,7 @@ func getDefaultProviders() []ProviderInfo { Input: ModalityCapabilities{Text: true, Audio: false, Image: true, Video: false, PDF: true}, Output: ModalityCapabilities{Text: true, Audio: false, Image: false, Video: false, PDF: false}, }, - Cost: ModelCost{Input: 3.0, Output: 15.0, CacheRead: 0.3, CacheWrite: 3.75}, + Cost: ModelCost{Input: 3.0, Output: 15.0, Cache: ModelCostCache{Read: 0.3, Write: 3.75}}, Limit: ModelLimit{Context: 200000, Output: 64000}, Options: map[string]any{}, }, @@ -142,7 +149,7 @@ func getDefaultProviders() []ProviderInfo { Input: ModalityCapabilities{Text: true, Audio: false, Image: true, Video: false, PDF: true}, Output: ModalityCapabilities{Text: true, Audio: false, Image: false, Video: false, PDF: false}, }, - Cost: ModelCost{Input: 15.0, Output: 75.0, CacheRead: 1.5, CacheWrite: 18.75}, + Cost: ModelCost{Input: 15.0, Output: 75.0, Cache: ModelCostCache{Read: 1.5, Write: 18.75}}, Limit: ModelLimit{Context: 200000, Output: 32000}, Options: map[string]any{}, }, @@ -158,7 +165,7 @@ func getDefaultProviders() []ProviderInfo { Input: ModalityCapabilities{Text: true, Audio: false, Image: true, Video: false, PDF: true}, Output: ModalityCapabilities{Text: true, Audio: false, Image: false, Video: false, PDF: false}, }, - Cost: ModelCost{Input: 0.8, Output: 4.0, CacheRead: 0.08, CacheWrite: 1.0}, + Cost: ModelCost{Input: 0.8, Output: 4.0, Cache: ModelCostCache{Read: 0.08, Write: 1.0}}, Limit: ModelLimit{Context: 200000, Output: 8192}, Options: map[string]any{}, }, @@ -182,7 +189,7 @@ func getDefaultProviders() []ProviderInfo { Input: ModalityCapabilities{Text: true, Audio: false, Image: true, Video: false, PDF: false}, Output: ModalityCapabilities{Text: true, Audio: false, Image: false, Video: false, PDF: false}, }, - Cost: ModelCost{Input: 2.5, Output: 10.0}, + Cost: ModelCost{Input: 2.5, Output: 10.0, Cache: ModelCostCache{Read: 0, Write: 0}}, Limit: ModelLimit{Context: 128000, Output: 16384}, Options: map[string]any{}, }, @@ -198,7 +205,7 @@ func getDefaultProviders() []ProviderInfo { Input: ModalityCapabilities{Text: true, Audio: false, Image: true, Video: false, PDF: false}, Output: ModalityCapabilities{Text: true, Audio: false, Image: false, Video: false, PDF: false}, }, - Cost: ModelCost{Input: 0.15, Output: 0.6}, + Cost: ModelCost{Input: 0.15, Output: 0.6, Cache: ModelCostCache{Read: 0, Write: 0}}, Limit: ModelLimit{Context: 128000, Output: 16384}, Options: map[string]any{}, }, @@ -937,7 +944,9 @@ func (s *Server) executeCommand(w http.ResponseWriter, r *http.Request) { } var req struct { - Args string `json:"args"` + Args string `json:"args"` + SessionID string `json:"sessionID,omitempty"` + MessageID string `json:"messageID,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Empty body is ok @@ -950,6 +959,17 @@ func (s *Server) executeCommand(w http.ResponseWriter, r *http.Request) { return } + // Publish command.executed event + event.PublishSync(event.Event{ + Type: event.CommandExecuted, + Data: event.CommandExecutedData{ + Name: name, + SessionID: req.SessionID, + Arguments: req.Args, + MessageID: req.MessageID, + }, + }) + writeJSON(w, http.StatusOK, result) } diff --git a/go-opencode/internal/server/handlers_file.go b/go-opencode/internal/server/handlers_file.go index 8b4ca7ae7b3..6e8ec08d8cb 100644 --- a/go-opencode/internal/server/handlers_file.go +++ b/go-opencode/internal/server/handlers_file.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/opencode-ai/opencode/internal/lsp" + "github.com/opencode-ai/opencode/internal/vcs" ) // FileInfo represents file information. @@ -263,13 +264,17 @@ var symbolKindsFilter = map[lsp.SymbolKind]bool{ func (s *Server) getVCSInfo(w http.ResponseWriter, r *http.Request) { directory := getDirectory(r.Context()) - // Get current branch - cmd := exec.Command("git", "branch", "--show-current") - cmd.Dir = directory - branch, _ := cmd.Output() + // Use cached branch from watcher if available for the same directory + var branch string + if s.vcsWatcher != nil && directory == s.config.Directory { + branch = s.vcsWatcher.CurrentBranch() + } else { + // Fallback to direct git command + branch = vcs.GetBranch(directory) + } writeJSON(w, http.StatusOK, map[string]any{ - "branch": strings.TrimSpace(string(branch)), + "branch": branch, }) } diff --git a/go-opencode/internal/server/handlers_tui.go b/go-opencode/internal/server/handlers_tui.go index 0ee095ecac8..d6128b859fd 100644 --- a/go-opencode/internal/server/handlers_tui.go +++ b/go-opencode/internal/server/handlers_tui.go @@ -6,6 +6,7 @@ import ( "time" "github.com/opencode-ai/opencode/internal/clienttool" + "github.com/opencode-ai/opencode/internal/event" ) // TUI control handlers for the TUI client. @@ -19,7 +20,13 @@ func (s *Server) tuiAppendPrompt(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "Invalid JSON body") return } - // TUI would receive this via SSE + + // Publish event for TUI clients via SSE + event.PublishSync(event.Event{ + Type: event.TuiPromptAppend, + Data: event.TuiPromptAppendData{Text: req.Text}, + }) + writeSuccess(w) } @@ -32,32 +39,84 @@ func (s *Server) tuiExecuteCommand(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "Invalid JSON body") return } + + // Publish event for TUI clients via SSE + event.PublishSync(event.Event{ + Type: event.TuiCommandExecute, + Data: event.TuiCommandExecuteData{Command: req.Command}, + }) + writeSuccess(w) } // tuiShowToast handles POST /tui/show-toast func (s *Server) tuiShowToast(w http.ResponseWriter, r *http.Request) { var req struct { - Message string `json:"message"` - Type string `json:"type"` // "info" | "error" | "success" + Title string `json:"title,omitempty"` + Message string `json:"message"` + Variant string `json:"variant"` // "info" | "error" | "success" | "warning" + Duration int `json:"duration,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "Invalid JSON body") return } + + // Publish event for TUI clients via SSE + event.PublishSync(event.Event{ + Type: event.TuiToastShow, + Data: event.TuiToastShowData{ + Title: req.Title, + Message: req.Message, + Variant: req.Variant, + Duration: req.Duration, + }, + }) + writeSuccess(w) } -// tuiPublish handles POST /tui/publish +// tuiPublish handles POST /tui/publish (generic event publisher) func (s *Server) tuiPublish(w http.ResponseWriter, r *http.Request) { var req struct { - Event string `json:"event"` - Data any `json:"data"` + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "Invalid JSON body") return } + + switch req.Type { + case "tui.prompt.append": + var data event.TuiPromptAppendData + if err := json.Unmarshal(req.Properties, &data); err != nil { + writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "invalid properties") + return + } + event.PublishSync(event.Event{Type: event.TuiPromptAppend, Data: data}) + + case "tui.command.execute": + var data event.TuiCommandExecuteData + if err := json.Unmarshal(req.Properties, &data); err != nil { + writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "invalid properties") + return + } + event.PublishSync(event.Event{Type: event.TuiCommandExecute, Data: data}) + + case "tui.toast.show": + var data event.TuiToastShowData + if err := json.Unmarshal(req.Properties, &data); err != nil { + writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "invalid properties") + return + } + event.PublishSync(event.Event{Type: event.TuiToastShow, Data: data}) + + default: + writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "unknown event type: "+req.Type) + return + } + writeSuccess(w) } diff --git a/go-opencode/internal/server/server.go b/go-opencode/internal/server/server.go index 4a8cc470879..b907a202766 100644 --- a/go-opencode/internal/server/server.go +++ b/go-opencode/internal/server/server.go @@ -21,6 +21,7 @@ import ( "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/storage" "github.com/opencode-ai/opencode/internal/tool" + "github.com/opencode-ai/opencode/internal/vcs" "github.com/opencode-ai/opencode/pkg/types" ) @@ -59,6 +60,7 @@ type Server struct { commandExecutor *command.Executor formatterManager *formatter.Manager lspClient *lsp.Client + vcsWatcher *vcs.Watcher } // New creates a new Server instance. @@ -89,6 +91,9 @@ func New(cfg *Config, appConfig *types.Config, store *storage.Storage, providerR lspDisabled := appConfig != nil && appConfig.LSP != nil && appConfig.LSP.Disabled lspClient := lsp.NewClient(cfg.Directory, lspDisabled) + // Initialize VCS watcher (watches for git branch changes) + vcsWatcher, _ := vcs.NewWatcher(cfg.Directory) + s := &Server{ config: cfg, router: r, @@ -102,6 +107,7 @@ func New(cfg *Config, appConfig *types.Config, store *storage.Storage, providerR commandExecutor: cmdExecutor, formatterManager: fmtManager, lspClient: lspClient, + vcsWatcher: vcsWatcher, } s.setupMiddleware() @@ -198,6 +204,11 @@ func (s *Server) instanceContext(next http.Handler) http.Handler { // Start starts the HTTP server. func (s *Server) Start() error { + // Start VCS watcher if available + if s.vcsWatcher != nil { + s.vcsWatcher.Start() + } + s.httpSrv = &http.Server{ Addr: fmt.Sprintf(":%d", s.config.Port), Handler: s.router, @@ -210,6 +221,10 @@ func (s *Server) Start() error { // Shutdown gracefully shuts down the server. func (s *Server) Shutdown(ctx context.Context) error { + // Stop VCS watcher if available + if s.vcsWatcher != nil { + _ = s.vcsWatcher.Stop() + } return s.httpSrv.Shutdown(ctx) } diff --git a/go-opencode/internal/vcs/watcher.go b/go-opencode/internal/vcs/watcher.go new file mode 100644 index 00000000000..56e1a682a60 --- /dev/null +++ b/go-opencode/internal/vcs/watcher.go @@ -0,0 +1,186 @@ +// Package vcs provides version control system integration. +package vcs + +import ( + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/opencode-ai/opencode/internal/event" + "github.com/rs/zerolog/log" +) + +// Watcher watches for git branch changes by monitoring .git/HEAD. +type Watcher struct { + watcher *fsnotify.Watcher + workDir string + gitDir string + currentBranch string + stopCh chan struct{} + doneCh chan struct{} + started bool + mu sync.RWMutex +} + +// NewWatcher creates a new VCS watcher for the given work directory. +// Returns nil if the directory is not a git repository. +func NewWatcher(workDir string) (*Watcher, error) { + gitDir := findGitDir(workDir) + if gitDir == "" { + log.Debug().Str("workDir", workDir).Msg("not a git repository, VCS watcher disabled") + return nil, nil + } + + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + // Watch the .git directory itself (to catch HEAD changes) + // On some systems, watching the file directly doesn't work reliably + if err := w.Add(gitDir); err != nil { + w.Close() + return nil, err + } + + branch := getCurrentBranch(workDir) + log.Info().Str("branch", branch).Str("gitDir", gitDir).Msg("VCS watcher initialized") + + return &Watcher{ + watcher: w, + workDir: workDir, + gitDir: gitDir, + currentBranch: branch, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + }, nil +} + +// Start begins watching for branch changes. +func (w *Watcher) Start() { + w.mu.Lock() + if w.started { + w.mu.Unlock() + return + } + w.started = true + w.mu.Unlock() + go w.run() +} + +func (w *Watcher) run() { + defer close(w.doneCh) + + for { + select { + case <-w.stopCh: + return + case ev, ok := <-w.watcher.Events: + if !ok { + return + } + // Check if this is a write event on HEAD or a relevant file + if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 { + // Check if the file is HEAD + if strings.HasSuffix(ev.Name, "HEAD") || strings.Contains(ev.Name, ".git") { + w.checkBranchChange() + } + } + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Error().Err(err).Msg("VCS watcher error") + } + } +} + +func (w *Watcher) checkBranchChange() { + newBranch := getCurrentBranch(w.workDir) + + w.mu.Lock() + oldBranch := w.currentBranch + changed := newBranch != oldBranch + if changed { + w.currentBranch = newBranch + } + w.mu.Unlock() + + if changed { + log.Info(). + Str("from", oldBranch). + Str("to", newBranch). + Msg("branch changed") + + event.PublishSync(event.Event{ + Type: event.VcsBranchUpdated, + Data: event.VcsBranchUpdatedData{Branch: newBranch}, + }) + } +} + +// CurrentBranch returns the currently tracked branch name. +func (w *Watcher) CurrentBranch() string { + w.mu.RLock() + defer w.mu.RUnlock() + return w.currentBranch +} + +// Stop stops the watcher. +func (w *Watcher) Stop() error { + w.mu.Lock() + started := w.started + w.mu.Unlock() + + // Signal stop + select { + case <-w.stopCh: + // Already stopped + default: + close(w.stopCh) + } + + // Wait for run() to finish if it was started + if started { + <-w.doneCh + } + + return w.watcher.Close() +} + +// findGitDir finds the .git directory for a given work directory. +// Handles both regular repos (.git directory) and worktrees (.git file). +func findGitDir(workDir string) string { + // Use git to find the actual git directory (handles worktrees too) + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = workDir + out, err := cmd.Output() + if err != nil { + return "" + } + + gitDir := strings.TrimSpace(string(out)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(workDir, gitDir) + } + + return gitDir +} + +// getCurrentBranch gets the current git branch name. +func getCurrentBranch(workDir string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = workDir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// GetBranch returns the current branch for a given directory (static helper). +func GetBranch(workDir string) string { + return getCurrentBranch(workDir) +} diff --git a/go-opencode/internal/vcs/watcher_test.go b/go-opencode/internal/vcs/watcher_test.go new file mode 100644 index 00000000000..285aacc359e --- /dev/null +++ b/go-opencode/internal/vcs/watcher_test.go @@ -0,0 +1,287 @@ +package vcs + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/opencode-ai/opencode/internal/event" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetBranch(t *testing.T) { + // Test in current directory (should be a git repo) + cwd, err := os.Getwd() + require.NoError(t, err) + + // Go up to find the repo root + repoRoot := findRepoRoot(cwd) + if repoRoot == "" { + t.Skip("Not running in a git repository") + } + + branch := GetBranch(repoRoot) + assert.NotEmpty(t, branch, "should return a branch name in a git repo") +} + +func TestGetBranch_NonGitDir(t *testing.T) { + // Create a temporary directory that's not a git repo + tmpDir, err := os.MkdirTemp("", "vcs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + branch := GetBranch(tmpDir) + assert.Empty(t, branch, "should return empty string for non-git directory") +} + +func TestNewWatcher_NonGitDir(t *testing.T) { + // Create a temporary directory that's not a git repo + tmpDir, err := os.MkdirTemp("", "vcs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + watcher, err := NewWatcher(tmpDir) + assert.NoError(t, err, "should not error for non-git directory") + assert.Nil(t, watcher, "should return nil watcher for non-git directory") +} + +func TestNewWatcher_GitRepo(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher, "should create watcher for git repository") + + // Clean up + err = watcher.Stop() + assert.NoError(t, err) +} + +func TestWatcher_CurrentBranch(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher) + defer watcher.Stop() + + branch := watcher.CurrentBranch() + assert.Equal(t, "main", branch, "should return the current branch") +} + +func TestWatcher_StartStop(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher) + + // Start and stop should work cleanly + watcher.Start() + err = watcher.Stop() + assert.NoError(t, err) +} + +func TestWatcher_CheckBranchChange(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + // Reset event bus for clean test + event.Reset() + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher) + defer watcher.Stop() + + // Subscribe to branch update events + eventReceived := make(chan event.VcsBranchUpdatedData, 1) + unsubscribe := event.Subscribe(event.VcsBranchUpdated, func(e event.Event) { + if data, ok := e.Data.(event.VcsBranchUpdatedData); ok { + select { + case eventReceived <- data: + default: + } + } + }) + defer unsubscribe() + + // Manually update the branch in the watcher and trigger check + runGit(t, tmpDir, "checkout", "-b", "feature-branch") + + // Manually call checkBranchChange (simulates what happens when file event is received) + watcher.checkBranchChange() + + // Should have received the event + select { + case data := <-eventReceived: + assert.Equal(t, "feature-branch", data.Branch, "should detect branch change") + case <-time.After(100 * time.Millisecond): + t.Fatal("should have received branch change event") + } + + // Verify watcher's cached branch is updated + assert.Equal(t, "feature-branch", watcher.CurrentBranch()) +} + +func TestWatcher_CheckBranchChange_NoChange(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + // Reset event bus for clean test + event.Reset() + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher) + defer watcher.Stop() + + // Subscribe to branch update events + eventReceived := make(chan event.VcsBranchUpdatedData, 1) + unsubscribe := event.Subscribe(event.VcsBranchUpdated, func(e event.Event) { + if data, ok := e.Data.(event.VcsBranchUpdatedData); ok { + select { + case eventReceived <- data: + default: + } + } + }) + defer unsubscribe() + + // Call checkBranchChange without actually changing the branch + watcher.checkBranchChange() + + // Should NOT receive an event + select { + case <-eventReceived: + t.Fatal("should not receive event when branch hasn't changed") + case <-time.After(50 * time.Millisecond): + // Expected - no event + } + + // Branch should still be main + assert.Equal(t, "main", watcher.CurrentBranch()) +} + +func TestFindGitDir(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + gitDir := findGitDir(tmpDir) + assert.NotEmpty(t, gitDir, "should find .git directory") + assert.True(t, filepath.IsAbs(gitDir), "should return absolute path") + + // Verify it ends with .git + assert.Equal(t, ".git", filepath.Base(gitDir)) +} + +func TestFindGitDir_NonGitDir(t *testing.T) { + // Create a temporary directory that's not a git repo + tmpDir, err := os.MkdirTemp("", "vcs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + gitDir := findGitDir(tmpDir) + assert.Empty(t, gitDir, "should return empty string for non-git directory") +} + +func TestGetCurrentBranch(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + branch := getCurrentBranch(tmpDir) + assert.Equal(t, "main", branch, "should return 'main' for new repo") + + // Create and switch to a new branch + runGit(t, tmpDir, "checkout", "-b", "test-branch") + + branch = getCurrentBranch(tmpDir) + assert.Equal(t, "test-branch", branch, "should return new branch name") +} + +func TestWatcher_ConcurrentAccess(t *testing.T) { + // Create a temporary git repository + tmpDir := createTempGitRepo(t) + defer os.RemoveAll(tmpDir) + + watcher, err := NewWatcher(tmpDir) + require.NoError(t, err) + require.NotNil(t, watcher) + defer watcher.Stop() + + watcher.Start() + + // Concurrent reads should be safe + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 100; j++ { + _ = watcher.CurrentBranch() + } + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } +} + +// Helper functions + +func createTempGitRepo(t *testing.T) string { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "vcs-test-repo-*") + require.NoError(t, err) + + // Initialize git repo + runGit(t, tmpDir, "init", "-b", "main") + + // Configure git user (required for commits) + runGit(t, tmpDir, "config", "user.email", "test@example.com") + runGit(t, tmpDir, "config", "user.name", "Test User") + + // Create initial commit (required for branch operations) + testFile := filepath.Join(tmpDir, "README.md") + err = os.WriteFile(testFile, []byte("# Test\n"), 0644) + require.NoError(t, err) + + runGit(t, tmpDir, "add", ".") + runGit(t, tmpDir, "commit", "-m", "Initial commit") + + return tmpDir +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(output)) +} + +func findRepoRoot(dir string) string { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return filepath.Clean(string(out[:len(out)-1])) // Remove trailing newline +} diff --git a/go-opencode/pkg/types/config.go b/go-opencode/pkg/types/config.go index e4dba22b811..004dfea740f 100644 --- a/go-opencode/pkg/types/config.go +++ b/go-opencode/pkg/types/config.go @@ -10,8 +10,8 @@ type Config struct { Username string `json:"username,omitempty"` // Model selection - Model string `json:"model,omitempty"` // "anthropic/claude-sonnet-4" - SmallModel string `json:"small_model,omitempty"` // For fast tasks + Model string `json:"model,omitempty"` // "anthropic/claude-sonnet-4" + SmallModel string `json:"smallModel,omitempty"` // For fast tasks (camelCase for TS compatibility) // Theme (TUI only, for compatibility) Theme string `json:"theme,omitempty"` @@ -87,7 +87,7 @@ type ProviderConfig struct { type ModelConfig struct { ID string `json:"id,omitempty"` Reasoning bool `json:"reasoning,omitempty"` - ToolCall bool `json:"tool_call,omitempty"` + ToolCall bool `json:"toolcall,omitempty"` // No underscore - matches TS capabilities.toolcall } // ProviderOptions holds nested provider options (TypeScript style). @@ -106,7 +106,7 @@ type AgentConfig struct { // Generation parameters Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` // Changed to match TS (was topP) + TopP *float64 `json:"topP,omitempty"` // camelCase for TS compatibility // Custom system prompt Prompt string `json:"prompt,omitempty"` diff --git a/go-opencode/pkg/types/parts.go b/go-opencode/pkg/types/parts.go index 11875a5a5e7..ef075726b24 100644 --- a/go-opencode/pkg/types/parts.go +++ b/go-opencode/pkg/types/parts.go @@ -156,6 +156,112 @@ func (p *CompactionPart) PartID() string { return p.ID } func (p *CompactionPart) PartSessionID() string { return p.SessionID } func (p *CompactionPart) PartMessageID() string { return p.MessageID } +// SnapshotPart marks a git snapshot point. +// SDK compatible: includes sessionID and messageID fields. +type SnapshotPart struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` // SDK compatible + MessageID string `json:"messageID"` // SDK compatible + Type string `json:"type"` // always "snapshot" + Snapshot string `json:"snapshot"` // Git commit hash +} + +func (p *SnapshotPart) PartType() string { return "snapshot" } +func (p *SnapshotPart) PartID() string { return p.ID } +func (p *SnapshotPart) PartSessionID() string { return p.SessionID } +func (p *SnapshotPart) PartMessageID() string { return p.MessageID } + +// PatchPart represents a code patch. +// SDK compatible: includes sessionID and messageID fields. +type PatchPart struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` // SDK compatible + MessageID string `json:"messageID"` // SDK compatible + Type string `json:"type"` // always "patch" + Hash string `json:"hash"` // Patch hash + Files []string `json:"files"` // Affected files +} + +func (p *PatchPart) PartType() string { return "patch" } +func (p *PatchPart) PartID() string { return p.ID } +func (p *PatchPart) PartSessionID() string { return p.SessionID } +func (p *PatchPart) PartMessageID() string { return p.MessageID } + +// AgentPart represents an agent invocation. +// SDK compatible: includes sessionID and messageID fields. +type AgentPart struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` // SDK compatible + MessageID string `json:"messageID"` // SDK compatible + Type string `json:"type"` // always "agent" + Name string `json:"name"` // Agent name + Source *AgentPartSource `json:"source,omitempty"` +} + +// AgentPartSource contains the source text reference. +type AgentPartSource struct { + Value string `json:"value"` + Start int `json:"start"` + End int `json:"end"` +} + +func (p *AgentPart) PartType() string { return "agent" } +func (p *AgentPart) PartID() string { return p.ID } +func (p *AgentPart) PartSessionID() string { return p.SessionID } +func (p *AgentPart) PartMessageID() string { return p.MessageID } + +// SubtaskPart represents a subtask delegation. +// SDK compatible: includes sessionID and messageID fields. +type SubtaskPart struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` // SDK compatible + MessageID string `json:"messageID"` // SDK compatible + Type string `json:"type"` // always "subtask" + Prompt string `json:"prompt"` // Task prompt + Description string `json:"description"` // Task description + Agent string `json:"agent"` // Agent to use +} + +func (p *SubtaskPart) PartType() string { return "subtask" } +func (p *SubtaskPart) PartID() string { return p.ID } +func (p *SubtaskPart) PartSessionID() string { return p.SessionID } +func (p *SubtaskPart) PartMessageID() string { return p.MessageID } + +// RetryPart represents a retry attempt after an error. +// SDK compatible: includes sessionID and messageID fields. +type RetryPart struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` // SDK compatible + MessageID string `json:"messageID"` // SDK compatible + Type string `json:"type"` // always "retry" + Attempt int `json:"attempt"` // Retry attempt number + Error *APIError `json:"error"` // Error that caused the retry + Time RetryPartTime `json:"time"` +} + +// RetryPartTime contains the time of the retry. +type RetryPartTime struct { + Created int64 `json:"created"` +} + +// APIError represents an API error (used by RetryPart). +type APIError struct { + Name string `json:"name"` // Always "APIError" + Data APIErrorData `json:"data"` +} + +// APIErrorData contains API error details. +type APIErrorData struct { + Status int `json:"status,omitempty"` + Message string `json:"message"` + Retryable bool `json:"retryable,omitempty"` +} + +func (p *RetryPart) PartType() string { return "retry" } +func (p *RetryPart) PartID() string { return p.ID } +func (p *RetryPart) PartSessionID() string { return p.SessionID } +func (p *RetryPart) PartMessageID() string { return p.MessageID } + // RawPart is used for JSON unmarshaling of parts. type RawPart struct { ID string `json:"id"` @@ -213,6 +319,36 @@ func UnmarshalPart(data []byte) (Part, error) { return nil, err } return &p, nil + case "snapshot": + var p SnapshotPart + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case "patch": + var p PatchPart + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case "agent": + var p AgentPart + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case "subtask": + var p SubtaskPart + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case "retry": + var p RetryPart + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil default: // Return raw part for unknown types var p TextPart