diff --git a/CLAUDE.md b/CLAUDE.md index 69465368a46..e93b4d2ecf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,13 @@ This project contains the origin typescript opencode AI code assistant AND its port to golang in ./go-opencode. Any behavior of go-opencode must match with packages/opencode server implementation. We want to use the opencode TUI client to attach to the golang go-opencode server. + + +## TS Opencode + +See ./packages/opencode + + +## Go Opencode + +See ./go-opencode diff --git a/go-opencode/citest/testutil/server.go b/go-opencode/citest/testutil/server.go index 104802889b0..9e00b52b4aa 100644 --- a/go-opencode/citest/testutil/server.go +++ b/go-opencode/citest/testutil/server.go @@ -196,7 +196,7 @@ func StartTestServer(opts ...TestServerOption) (*TestServer, error) { } // Initialize tools - toolReg := tool.DefaultRegistry(workDir) + toolReg := tool.DefaultRegistry(workDir, store) // Configure server serverConfig := server.DefaultConfig() diff --git a/go-opencode/cmd/opencode/commands/run.go b/go-opencode/cmd/opencode/commands/run.go index 36b5c5aab6e..f1ae8febeef 100644 --- a/go-opencode/cmd/opencode/commands/run.go +++ b/go-opencode/cmd/opencode/commands/run.go @@ -97,7 +97,7 @@ func runInteractive(cmd *cobra.Command, args []string) error { } // Initialize tool registry - toolReg := tool.DefaultRegistry(workDir) + toolReg := tool.DefaultRegistry(workDir, store) // Initialize MCP client and servers from config var mcpClient *mcp.Client diff --git a/go-opencode/cmd/opencode/commands/serve.go b/go-opencode/cmd/opencode/commands/serve.go index 1abc4c8314f..49f31ee2185 100644 --- a/go-opencode/cmd/opencode/commands/serve.go +++ b/go-opencode/cmd/opencode/commands/serve.go @@ -83,7 +83,7 @@ func runServe(cmd *cobra.Command, args []string) error { } // Initialize tool registry - toolReg := tool.DefaultRegistry(workDir) + toolReg := tool.DefaultRegistry(workDir, store) // Configure server serverConfig := server.DefaultConfig() diff --git a/go-opencode/go.mod b/go-opencode/go.mod index 98bc53fd4c2..68d4120ddf7 100644 --- a/go-opencode/go.mod +++ b/go-opencode/go.mod @@ -41,7 +41,7 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.1 // indirect diff --git a/go-opencode/go.sum b/go-opencode/go.sum index 3f1d62a42f8..9bd3a3d5a63 100644 --- a/go-opencode/go.sum +++ b/go-opencode/go.sum @@ -7,8 +7,8 @@ github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892 github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= -github.com/anthropics/anthropic-sdk-go v1.4.0 h1:fU1jKxYbQdQDiEXCxeW5XZRIOwKevn/PMg8Ay1nnUx0= -github.com/anthropics/anthropic-sdk-go v1.4.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= +github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= +github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= diff --git a/go-opencode/internal/agent/doc.go b/go-opencode/internal/agent/doc.go new file mode 100644 index 00000000000..7d9739622c1 --- /dev/null +++ b/go-opencode/internal/agent/doc.go @@ -0,0 +1,81 @@ +// Package agent provides multi-agent configuration and management for opencode. +// +// This package implements a flexible agent system that supports different operation +// modes, tool access controls, and permission management. Agents can operate as +// primary agents (user-facing) or subagents (invoked by other agents). +// +// # Agent Types +// +// The package provides four built-in agents: +// +// - build: Primary agent for executing tasks, writing code, and making changes. +// Has full tool access and permissive permissions. +// - plan: Primary agent for analysis and exploration without making changes. +// Restricted to read-only operations. +// - general: Subagent for general-purpose searches and exploration. +// - explore: Fast subagent specialized for codebase exploration. +// +// # Agent Modes +// +// Agents operate in one of three modes: +// +// - ModePrimary: Can be selected as the main agent for a session +// - ModeSubagent: Can only be invoked by other agents via the Task tool +// - ModeAll: Can operate in both primary and subagent contexts +// +// # Tool Access Control +// +// Each agent has a Tools map that controls which tools are available. Tools can be +// enabled or disabled using exact names or wildcard patterns: +// +// agent.Tools = map[string]bool{ +// "*": true, // Enable all tools by default +// "bash": false, // Disable bash specifically +// "mcp_*": true, // Enable all MCP tools +// } +// +// The [Agent.ToolEnabled] method checks tool availability, supporting glob patterns +// including doublestar (**) for complex matching. +// +// # Permission System +// +// Agents define permissions for sensitive operations through [AgentPermission]: +// +// - Edit: Controls file editing permissions +// - Bash: Maps command patterns to permission actions +// - WebFetch: Controls web fetching permissions +// - ExternalDir: Controls access to directories outside the project +// - DoomLoop: Controls handling of repeated failure patterns +// +// Permission actions are: allow, deny, or ask (prompt user). +// +// # Registry +// +// The [Registry] type manages agent configurations with thread-safe operations: +// +// registry := agent.NewRegistry() // Includes built-in agents +// registry.Register(customAgent) // Add custom agent +// agent, err := registry.Get("build") +// primaryAgents := registry.ListPrimary() +// subagents := registry.ListSubagents() +// +// # Custom Configuration +// +// Custom agents can be loaded from configuration using [Registry.LoadFromConfig]. +// Configurations can extend or override built-in agents: +// +// config := map[string]agent.AgentConfig{ +// "build": { +// Temperature: 0.7, +// Permission: &agent.AgentPermissionConfig{ +// Edit: permission.ActionAsk, +// }, +// }, +// "custom": { +// Description: "Custom agent", +// Mode: agent.ModePrimary, +// Tools: map[string]bool{"read": true, "glob": true}, +// }, +// } +// registry.LoadFromConfig(config) +package agent diff --git a/go-opencode/internal/clienttool/registry.go b/go-opencode/internal/clienttool/registry.go index 5dc6ea18f3f..2a96bfaf1bd 100644 --- a/go-opencode/internal/clienttool/registry.go +++ b/go-opencode/internal/clienttool/registry.go @@ -105,7 +105,7 @@ func (r *Registry) Register(clientID string, tools []ToolDefinition) []string { } // Publish event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolRegistered, Data: event.ClientToolRegisteredData{ ClientID: clientID, @@ -154,7 +154,7 @@ func (r *Registry) Unregister(clientID string, toolIDs []string) []string { } if len(unregistered) > 0 { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolUnregistered, Data: event.ClientToolUnregisteredData{ ClientID: clientID, @@ -231,7 +231,7 @@ func (r *Registry) Execute(ctx context.Context, clientID string, req ExecutionRe r.mu.Unlock() // Publish event for SSE clients - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolRequest, Data: event.ClientToolRequestData{ ClientID: clientID, @@ -239,7 +239,7 @@ func (r *Registry) Execute(ctx context.Context, clientID string, req ExecutionRe }, }) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolExecuting, Data: event.ClientToolStatusData{ SessionID: req.SessionID, @@ -259,7 +259,7 @@ func (r *Registry) Execute(ctx context.Context, clientID string, req ExecutionRe r.mu.Unlock() if resp.Status == "error" { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolFailed, Data: event.ClientToolStatusData{ SessionID: req.SessionID, @@ -273,7 +273,7 @@ func (r *Registry) Execute(ctx context.Context, clientID string, req ExecutionRe return nil, errors.New(resp.Error) } - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolCompleted, Data: event.ClientToolStatusData{ SessionID: req.SessionID, @@ -297,7 +297,7 @@ func (r *Registry) Execute(ctx context.Context, clientID string, req ExecutionRe delete(r.pending, req.RequestID) r.mu.Unlock() - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolFailed, Data: event.ClientToolStatusData{ SessionID: req.SessionID, @@ -371,7 +371,7 @@ func (r *Registry) Cleanup(clientID string) { delete(r.tools, clientID) if len(toolIDs) > 0 { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.ClientToolUnregistered, Data: event.ClientToolUnregisteredData{ ClientID: clientID, diff --git a/go-opencode/internal/command/doc.go b/go-opencode/internal/command/doc.go new file mode 100644 index 00000000000..b178b712d50 --- /dev/null +++ b/go-opencode/internal/command/doc.go @@ -0,0 +1,101 @@ +// Package command provides a flexible command execution system for OpenCode. +// +// This package implements a custom command system that allows users to define +// and execute templated commands with variable substitution. Commands can be +// defined in configuration files or as markdown files in the .opencode/command +// directory. +// +// # Command Sources +// +// Commands can be loaded from two sources: +// +// 1. Configuration files: Commands defined in the OpenCode configuration +// 2. Markdown files: Commands stored as .md files in .opencode/command/ +// +// # Command Structure +// +// Each command consists of: +// - Name: Unique identifier for the command +// - Description: Human-readable description of what the command does +// - Template: The template string that will be executed with variable substitution +// - Agent: Optional agent to use for execution +// - Model: Optional model to use for execution +// - Subtask: Whether this command represents a subtask +// +// # Template System +// +// Commands use Go templates with additional support for simple variable substitution: +// +// - ${variable} syntax for variable expansion +// - $variable syntax for simple variable references +// - $1, $2, ... for positional arguments +// - $input for the full input string +// - --name=value or --name value for named arguments +// +// # Template Context +// +// Templates have access to: +// - args: Map of parsed arguments +// - input: The raw input string +// - vars: Configured prompt variables +// - env: Environment variables +// - workDir: Current working directory +// - Custom template functions (env, default, trim, upper, lower, etc.) +// +// # Markdown Command Format +// +// Markdown commands can include YAML frontmatter: +// +// --- +// description: Run tests +// agent: test-agent +// model: claude-3 +// subtask: true +// --- +// Run tests for ${1} package +// +// # Built-in Commands +// +// The package provides several built-in commands: +// - help: Show available commands and help information +// - clear: Clear the current conversation +// - compact: Compact the conversation to save context +// - reset: Reset the session to its initial state +// - undo: Undo the last message +// - share: Share the current session +// - export: Export the conversation +// +// # Example Usage +// +// // Create executor +// executor := NewExecutor("/path/to/work/dir", config) +// +// // Execute a command +// result, err := executor.Execute(ctx, "greet", "World") +// if err != nil { +// log.Fatal(err) +// } +// +// // Use the generated prompt +// fmt.Println(result.Prompt) // "Hello, World!" +// +// # Dynamic Command Management +// +// Commands can be managed at runtime: +// +// // Add a new command +// executor.AddCommand(&Command{ +// Name: "custom", +// Template: "Custom command with $1", +// }) +// +// // Remove a command +// executor.RemoveCommand("custom") +// +// // Reload all commands +// executor.Reload() +// +// The command system is designed to be flexible and extensible, supporting +// both simple string substitution and complex Go template logic while +// maintaining ease of use for end users. +package command \ No newline at end of file diff --git a/go-opencode/internal/config/doc.go b/go-opencode/internal/config/doc.go new file mode 100644 index 00000000000..6833797e9ec --- /dev/null +++ b/go-opencode/internal/config/doc.go @@ -0,0 +1,122 @@ +// Package config provides configuration loading, merging, and path management for OpenCode. +// +// This package handles the complex configuration system that supports multiple sources +// and formats, with a hierarchical loading strategy that ensures proper precedence +// and compatibility with both TypeScript and Go implementations. +// +// # Configuration Loading +// +// The Load function implements a sophisticated configuration loading strategy that +// searches for and merges configuration from multiple sources in priority order: +// +// 1. Global config (~/.opencode/ - TypeScript compatible) +// 2. Global config (~/.config/opencode/ - XDG compatible) +// 3. Project configs discovered while walking up from the working directory +// (opencode.json/opencode.jsonc and .opencode/opencode.json/opencode.jsonc) +// 4. OPENCODE_CONFIG file +// 5. OPENCODE_CONFIG_CONTENT inline JSON +// 6. Environment variables +// +// Configuration files are loaded in a specific order to ensure that more specific +// configurations override more general ones, while environment variables have the +// highest precedence. +// +// # Supported Formats +// +// The package supports both JSON and JSONC (JSON with Comments) formats: +// - opencode.json - Standard JSON configuration +// - opencode.jsonc - JSON with comments, processed using tidwall/jsonc +// +// # Variable Interpolation +// +// Configuration files support two types of variable interpolation: +// - {env:VAR_NAME} - Expands to environment variable values +// - {file:path} - Expands to file contents (properly escaped for JSON) +// +// File paths in {file:path} placeholders support: +// - Absolute paths +// - Relative paths (resolved relative to config file directory) +// - Home directory expansion (~/) +// +// Example configuration with interpolation: +// +// { +// "provider": { +// "anthropic": { +// "options": { +// "apiKey": "{env:ANTHROPIC_API_KEY}" +// } +// } +// }, +// "instructions": [ +// "{file:~/custom-instructions.txt}" +// ] +// } +// +// # Configuration Merging +// +// When multiple configuration sources are found, they are merged using a deep merge +// strategy that: +// - Overwrites scalar values (strings, booleans, numbers) +// - Merges maps/objects by combining keys +// - Appends to arrays/slices +// - Preserves the last-loaded value for conflicts +// +// # Path Management +// +// The package provides XDG Base Directory Specification compliant path management +// through the Paths type: +// - Data: ~/.local/share/opencode (XDG_DATA_HOME) +// - Config: ~/.config/opencode (XDG_CONFIG_HOME) +// - Cache: ~/.cache/opencode (XDG_CACHE_HOME) +// - State: ~/.local/state/opencode (XDG_STATE_HOME) +// +// On Windows, these paths are adapted to use APPDATA as appropriate. +// +// # Environment Variable Overrides +// +// Several environment variables provide direct configuration overrides: +// - OPENCODE_MODEL - Override the default model +// - OPENCODE_SMALL_MODEL - Override the small model +// - OPENCODE_PERMISSION - JSON string for permission configuration +// - OPENCODE_CONFIG - Path to a specific config file +// - OPENCODE_CONFIG_CONTENT - Inline JSON configuration +// - OPENCODE_CONFIG_DIR - Override the config directory location +// +// # TypeScript Compatibility +// +// The configuration system maintains compatibility with the TypeScript implementation +// by supporting the ~/.opencode directory structure and TypeScript-style provider +// configuration with Options objects. +// +// # Usage Example +// +// // Load configuration from the current directory +// config, err := config.Load(".") +// if err != nil { +// log.Fatal(err) +// } +// +// // Get standard paths +// paths := config.GetPaths() +// err = paths.EnsurePaths() // Create directories if they don't exist +// if err != nil { +// log.Fatal(err) +// } +// +// // Save configuration +// err = config.Save(config, paths.GlobalConfigPath()) +// if err != nil { +// log.Fatal(err) +// } +// +// # Project Structure Discovery +// +// The configuration loader walks up the directory tree from the specified starting +// directory, stopping at either: +// - A directory containing a .git folder (Git repository root) +// - The filesystem root +// +// This ensures that project-specific configurations are properly discovered while +// respecting project boundaries. +package config \ No newline at end of file diff --git a/go-opencode/internal/event/bus.go b/go-opencode/internal/event/bus.go index c0570851909..61e821cd0a1 100644 --- a/go-opencode/internal/event/bus.go +++ b/go-opencode/internal/event/bus.go @@ -1,4 +1,26 @@ // Package event provides a pub/sub event system for the server using watermill. +// +// # Subscriber Requirements +// +// When using PublishSync, subscribers are called synchronously in the publisher's +// goroutine. To avoid blocking or deadlocks, subscribers MUST: +// +// - Complete quickly (avoid long-running operations) +// - Use non-blocking channel sends (select with default case) +// - Never call Publish/PublishSync from within a subscriber (no re-entrant publishing) +// - Never acquire locks that the publisher might hold +// +// Example of a safe subscriber: +// +// event.SubscribeAll(func(e event.Event) { +// select { +// case eventChan <- e: +// // Event sent successfully +// default: +// // Channel full, drop event to avoid blocking +// log.Warn("Event dropped due to full channel", "type", e.Type) +// } +// }) package event import ( @@ -31,6 +53,7 @@ const ( FileEdited EventType = "file.edited" PermissionUpdated EventType = "permission.updated" // SDK compatible (was permission.required) PermissionReplied EventType = "permission.replied" // SDK compatible (was permission.resolved) + TodoUpdated EventType = "todo.updated" // Client Tool Events ClientToolRequest EventType = "client-tool.request" diff --git a/go-opencode/internal/event/doc.go b/go-opencode/internal/event/doc.go new file mode 100644 index 00000000000..1b81c0e7816 --- /dev/null +++ b/go-opencode/internal/event/doc.go @@ -0,0 +1,154 @@ +/* +Package event provides a type-safe, pub/sub event system for the OpenCode server. + +The event system enables decoupled communication between different components of the +server by allowing publishers to emit events and subscribers to react to them without +direct dependencies. + +# Architecture + +The package is built on top of watermill's gochannel for infrastructure while maintaining +direct-call semantics to preserve type information. It provides both synchronous and +asynchronous event publishing patterns. + +# Event Types + +The system supports various event categories: + +Session Events: + - session.created: New session created + - session.updated: Session modified + - session.deleted: Session removed + - session.idle: Session became idle + - session.status: Session status changed + - session.diff: File differences detected + - session.error: Session error occurred + - session.compacted: Session history compacted + +Message Events: + - message.created: New message added + - message.updated: Message modified + - message.removed: Message deleted + - message.part.updated: Message part updated (streaming) + - message.part.removed: Message part removed + +File Events: + - file.edited: File was modified + +Permission Events: + - permission.updated: Permission request created + - permission.replied: Permission request responded to + +Client Tool Events: + - client-tool.request: Tool execution requested + - client-tool.registered: Tools registered by client + - client-tool.unregistered: Tools unregistered by client + - client-tool.executing: Tool execution started + - client-tool.completed: Tool execution completed + - client-tool.failed: Tool execution failed + +# Basic Usage + +Publishing events: + + // Asynchronous publishing (non-blocking) + event.Publish(event.Event{ + Type: event.SessionCreated, + Data: event.SessionCreatedData{ + Info: session, + }, + }) + + // Synchronous publishing (blocking until all subscribers complete) + event.PublishSync(event.Event{ + Type: event.MessageUpdated, + Data: event.MessageUpdatedData{ + Info: message, + }, + }) + +Subscribing to specific events: + + unsubscribe := event.Subscribe(event.SessionCreated, func(e event.Event) { + data := e.Data.(event.SessionCreatedData) + log.Info("Session created", "id", data.Info.ID) + }) + defer unsubscribe() + +Subscribing to all events: + + unsubscribe := event.SubscribeAll(func(e event.Event) { + log.Debug("Event received", "type", e.Type) + }) + defer unsubscribe() + +# Subscriber Safety Guidelines + +When using PublishSync, subscribers are called synchronously in the publisher's +goroutine. To avoid blocking or deadlocks, subscribers MUST: + + - Complete quickly (avoid long-running operations) + - Use non-blocking channel sends (select with default case) + - Never call Publish/PublishSync from within a subscriber (no re-entrant publishing) + - Never acquire locks that the publisher might hold + +Example of a safe subscriber: + + event.SubscribeAll(func(e event.Event) { + select { + case eventChan <- e: + // Event sent successfully + default: + // Channel full, drop event to avoid blocking + log.Warn("Event dropped due to full channel", "type", e.Type) + } + }) + +# Custom Event Bus + +For testing or isolation, you can create custom bus instances: + + bus := event.NewBus() + defer bus.Close() + + unsubscribe := bus.Subscribe(event.SessionCreated, handler) + bus.PublishSync(event.Event{Type: event.SessionCreated, Data: data}) + +# SDK Compatibility + +Many event types and data structures are designed to be compatible with the OpenCode +SDK. Event names and data field names follow SDK conventions where possible, with +compatibility notes in the type definitions. + +# Testing + +The package provides utilities for testing: + + // Reset global bus state (use in test cleanup) + event.Reset() + +# Thread Safety + +The event bus is thread-safe and can be used concurrently from multiple goroutines. +Both publishing and subscribing operations are protected by internal synchronization. + +# Performance Considerations + +- Asynchronous publishing (Publish) creates a goroutine per subscriber per event +- Synchronous publishing (PublishSync) calls all subscribers in the current goroutine +- Use PublishSync for critical events where ordering matters +- Use Publish for fire-and-forget notifications +- Consider subscriber performance impact on PublishSync calls + +# Integration with Watermill + +The package uses watermill's gochannel internally, providing access to the underlying +pubsub infrastructure for advanced use cases: + + pubsub := event.PubSub() + // Use watermill features like middleware, routing, etc. + +This allows future migration to distributed message brokers if needed while maintaining +the current API. +*/ +package event \ No newline at end of file diff --git a/go-opencode/internal/mcp/doc.go b/go-opencode/internal/mcp/doc.go new file mode 100644 index 00000000000..eb109276ac6 --- /dev/null +++ b/go-opencode/internal/mcp/doc.go @@ -0,0 +1,157 @@ +// Package mcp provides Model Context Protocol (MCP) client functionality for +// integrating with MCP servers using the official MCP Go SDK. +// +// The Model Context Protocol (MCP) is an open standard that enables secure +// connections between host applications (like IDEs, chat interfaces, or +// other tools) and external data sources and tools. This package implements +// a client that can connect to MCP servers and expose their tools, resources, +// and prompts to the OpenCode system. +// +// # Key Features +// +// • Multiple transport types: stdio, local command execution, and remote HTTP +// • Tool execution with automatic registration in the tool registry +// • Resource access for reading files and data from MCP servers +// • Prompt management for interacting with server-provided prompts +// • Connection management with status monitoring and error handling +// • Thread-safe operations with proper synchronization +// +// # Transport Types +// +// The package supports three transport mechanisms: +// +// TransportTypeStdio - Communication via stdin/stdout with a subprocess +// TransportTypeLocal - Direct execution of local commands +// TransportTypeRemote - HTTP-based communication with remote servers +// +// # Basic Usage +// +// // Create a new MCP client +// client := mcp.NewClient() +// +// // Configure a server connection +// config := &mcp.Config{ +// Enabled: true, +// Type: mcp.TransportTypeStdio, +// Command: []string{"python", "-m", "my_mcp_server"}, +// Timeout: 5000, // 5 seconds +// } +// +// // Add and connect to the server +// err := client.AddServer(ctx, "my-server", config) +// if err != nil { +// log.Fatal(err) +// } +// +// // List available tools +// tools := client.Tools() +// for _, tool := range tools { +// fmt.Printf("Tool: %s - %s\n", tool.Name, tool.Description) +// } +// +// // Execute a tool +// args := json.RawMessage(`{"query": "example"}`) +// result, err := client.ExecuteTool(ctx, "my-server_search", args) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println("Result:", result) +// +// # Tool Integration +// +// MCP tools are automatically wrapped and can be registered in the standard +// tool registry using MCPToolWrapper. This allows them to be used seamlessly +// in the agentic execution loop: +// +// // Wrap an MCP tool for use in the tool registry +// wrapper := mcp.NewMCPToolWrapper(mcpTool, client) +// +// // Register in the tool registry (typically done automatically) +// registry.RegisterTool(wrapper.ID(), wrapper) +// +// # Configuration +// +// Server configurations support various options: +// +// config := &mcp.Config{ +// Enabled: true, +// Type: mcp.TransportTypeRemote, +// URL: "http://localhost:8080/mcp", +// Headers: map[string]string{"Authorization": "Bearer token"}, +// Environment: map[string]string{"API_KEY": "secret"}, +// Timeout: 10000, // 10 seconds +// } +// +// # Error Handling +// +// The package provides comprehensive error handling and status monitoring: +// +// // Check server status +// status := client.Status() +// for _, server := range status { +// if server.Status == mcp.StatusFailed { +// fmt.Printf("Server %s failed: %s\n", server.Name, *server.Error) +// } +// } +// +// // Get specific server status +// serverStatus, err := client.GetServer("my-server") +// if err != nil { +// log.Printf("Server not found: %v", err) +// } +// +// # Resource Access +// +// MCP servers can expose resources (files, data sources, etc.) that can be +// accessed through the client: +// +// // List available resources +// resources, err := client.ListResources(ctx) +// if err != nil { +// log.Fatal(err) +// } +// +// // Read a specific resource +// response, err := client.ReadResource(ctx, "file:///path/to/file.txt") +// if err != nil { +// log.Fatal(err) +// } +// +// for _, content := range response.Contents { +// fmt.Printf("Content: %s\n", content.Text) +// } +// +// # Connection Management +// +// The client manages multiple server connections concurrently: +// +// // Get connection statistics +// total := client.ServerCount() +// connected := client.ConnectedCount() +// fmt.Printf("Servers: %d total, %d connected\n", total, connected) +// +// // Remove a server +// err := client.RemoveServer("my-server") +// if err != nil { +// log.Printf("Failed to remove server: %v", err) +// } +// +// // Close all connections +// err = client.Close() +// if err != nil { +// log.Printf("Error closing client: %v", err) +// } +// +// # Thread Safety +// +// All client operations are thread-safe and can be called concurrently from +// multiple goroutines. The client uses appropriate synchronization mechanisms +// to ensure data consistency and prevent race conditions. +// +// # Protocol Version +// +// This package implements MCP protocol version 2024-11-05 using the official +// MCP Go SDK. It provides compatibility with standard MCP servers and follows +// the protocol specifications for tool execution, resource access, and +// communication patterns. +package mcp \ No newline at end of file diff --git a/go-opencode/internal/mcp/mcp_e2e_test.go b/go-opencode/internal/mcp/mcp_e2e_test.go index 4c89164ff2c..787333a4666 100644 --- a/go-opencode/internal/mcp/mcp_e2e_test.go +++ b/go-opencode/internal/mcp/mcp_e2e_test.go @@ -38,7 +38,7 @@ func TestMCP_E2E_StdioTransport(t *testing.T) { require.NoError(t, err) // Register tools in registry - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Execute tool via registry @@ -95,7 +95,7 @@ func TestMCP_E2E_SSETransport(t *testing.T) { require.NoError(t, err) // Register tools in registry - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Execute tool via registry @@ -149,7 +149,7 @@ func TestMCP_E2E_MultipleServers(t *testing.T) { assert.Equal(t, StatusConnected, status2.Status) // Register tools in registry - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Verify both servers' tools are registered @@ -210,7 +210,7 @@ func TestMCP_E2E_ServerFailure(t *testing.T) { assert.NotNil(t, status.Error, "server should have error message") // Create tool registry and register tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Registry should have no tools from the failed server @@ -241,7 +241,7 @@ func TestMCP_E2E_ToolExecutionTimeout(t *testing.T) { require.NoError(t, err) // Register tools in registry - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Get the sum tool @@ -295,7 +295,7 @@ func TestMCP_E2E_ServerDisconnection(t *testing.T) { require.NoError(t, err) // Register tools and verify - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) sumTool, ok := registry.Get("calc_disconnect_sum") @@ -348,7 +348,7 @@ func TestMCP_E2E_DisabledServer(t *testing.T) { assert.Equal(t, StatusDisabled, status.Status, "server should be disabled") // Create tool registry and register tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Registry should have no tools from the disabled server @@ -387,7 +387,7 @@ func TestMCP_E2E_EnvironmentVariables(t *testing.T) { assert.Equal(t, StatusConnected, status.Status) // Register and execute tool (verifies the server works with env vars) - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) sumTool, ok := registry.Get("calc_env_sum") @@ -533,7 +533,7 @@ func TestMCP_E2E_MixedTransports(t *testing.T) { require.NoError(t, err) // Register tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Execute tool from stdio server diff --git a/go-opencode/internal/mcp/tool_wrapper_integration_test.go b/go-opencode/internal/mcp/tool_wrapper_integration_test.go index e552028a34b..3b917d1b45c 100644 --- a/go-opencode/internal/mcp/tool_wrapper_integration_test.go +++ b/go-opencode/internal/mcp/tool_wrapper_integration_test.go @@ -35,7 +35,7 @@ func TestRegisterMCPTools_WithCalculator(t *testing.T) { require.NoError(t, err, "failed to add calculator server") // Create tool registry and register MCP tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Verify the sum tool is registered with prefixed name @@ -78,7 +78,7 @@ func TestRegisterMCPTools_EinoToolExecution(t *testing.T) { require.NoError(t, err) // Create tool registry and register MCP tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Get the sum tool @@ -124,7 +124,7 @@ func TestRegisterMCPTools_ToolListContainsMCPTools(t *testing.T) { require.NoError(t, err) // Create tool registry with built-in tools (using a temp dir for workDir) - registry := tool.DefaultRegistry(t.TempDir()) + registry := tool.DefaultRegistry(t.TempDir(), nil) // Count built-in tools before MCP registration builtInCount := len(registry.List()) @@ -173,7 +173,7 @@ func TestMCPToolWrapper_ExecuteWithContext(t *testing.T) { require.NoError(t, err) // Create tool registry and register MCP tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Get the sum tool @@ -225,7 +225,7 @@ func TestMCPToolWrapper_ErrorHandling(t *testing.T) { require.NoError(t, err) // Create tool registry and register MCP tools - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) RegisterMCPTools(client, registry) // Get the sum tool diff --git a/go-opencode/internal/mcp/tool_wrapper_test.go b/go-opencode/internal/mcp/tool_wrapper_test.go index 862df0d8dcc..1d0273d2deb 100644 --- a/go-opencode/internal/mcp/tool_wrapper_test.go +++ b/go-opencode/internal/mcp/tool_wrapper_test.go @@ -164,7 +164,7 @@ func TestParseInputSchemaToParams(t *testing.T) { } func TestRegisterMCPTools_NilClient(t *testing.T) { - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) // Should not panic with nil client RegisterMCPTools(nil, registry) @@ -184,7 +184,7 @@ func TestRegisterMCPTools_NilRegistry(t *testing.T) { func TestRegisterMCPTools_NoServers(t *testing.T) { client := NewClient() defer client.Close() - registry := tool.NewRegistry("") + registry := tool.NewRegistry("", nil) // Register with no connected servers RegisterMCPTools(client, registry) diff --git a/go-opencode/internal/permission/checker.go b/go-opencode/internal/permission/checker.go index 77b9d8ae30a..42bfc82a028 100644 --- a/go-opencode/internal/permission/checker.go +++ b/go-opencode/internal/permission/checker.go @@ -100,7 +100,7 @@ func (c *Checker) Ask(ctx context.Context, req Request) error { }() // Publish permission request event (SDK compatible: uses PermissionUpdated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.PermissionUpdated, Data: event.PermissionUpdatedData{ ID: req.ID, @@ -151,7 +151,7 @@ func (c *Checker) Respond(requestID string, action string) { } // Publish resolved event (SDK compatible: uses PermissionReplied with sessionID) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.PermissionReplied, Data: event.PermissionRepliedData{ PermissionID: requestID, diff --git a/go-opencode/internal/permission/doc.go b/go-opencode/internal/permission/doc.go new file mode 100644 index 00000000000..6869541ece0 --- /dev/null +++ b/go-opencode/internal/permission/doc.go @@ -0,0 +1,124 @@ +// Package permission provides a comprehensive permission control system for tool execution +// in the OpenCode AI assistant. It manages user consent for potentially dangerous operations +// like file editing, web fetching, external directory access, and bash command execution. +// +// # Overview +// +// The permission system operates on a session-based model where each user interaction +// session can have different permission levels. It supports three main permission actions: +// - Allow: Automatically approve the operation +// - Deny: Automatically reject the operation +// - Ask: Prompt the user for consent +// +// # Permission Types +// +// The system handles several types of operations: +// +// - Bash: Command execution with pattern-based matching +// - Edit: File modification operations +// - WebFetch: External web resource access +// - ExternalDir: Operations outside the working directory +// - DoomLoop: Detection and prevention of infinite tool call loops +// +// # Core Components +// +// ## Checker +// +// The Checker is the central component that manages permission requests and approvals. +// It maintains session-based state for approved permissions and handles user prompts +// through an event system. +// +// checker := NewChecker() +// req := Request{ +// Type: PermBash, +// SessionID: "session-123", +// Pattern: []string{"git *"}, +// Title: "Execute git command", +// } +// err := checker.Check(ctx, req, ActionAsk) +// +// ## Bash Command Parsing +// +// The system includes sophisticated bash command parsing that extracts command names, +// arguments, and subcommands for fine-grained permission control: +// +// commands, err := ParseBashCommand("git commit -m 'fix bug'") +// // Returns: BashCommand{Name: "git", Subcommand: "commit", Args: ["-m", "fix bug"]} +// +// ## Pattern Matching +// +// Bash permissions support wildcard patterns with hierarchical matching: +// - "git commit *" - Matches git commit with any arguments +// - "git *" - Matches any git subcommand +// - "git" - Matches git command exactly +// - "*" - Matches any command +// +// ## Doom Loop Detection +// +// The DoomLoopDetector prevents infinite loops by tracking tool call patterns: +// +// detector := NewDoomLoopDetector() +// isLoop := detector.Check(sessionID, "bash", commandInput) +// if isLoop { +// // Handle potential infinite loop +// } +// +// # Permission Configuration +// +// AgentPermissions defines the permission policy for an agent: +// +// permissions := AgentPermissions{ +// Edit: ActionAsk, +// WebFetch: ActionAllow, +// ExternalDir: ActionDeny, +// DoomLoop: ActionAsk, +// Bash: map[string]PermissionAction{ +// "git *": ActionAllow, +// "rm *": ActionAsk, +// "sudo *": ActionDeny, +// }, +// } +// +// # Session Management +// +// The system maintains per-session state for approved permissions. When a user +// grants "always" permission, it's remembered for the duration of the session: +// +// // Clear all approvals for a session +// checker.ClearSession("session-123") +// +// // Check if permission is already approved +// if checker.IsApproved("session-123", PermBash) { +// // Skip asking user +// } +// +// # Error Handling +// +// Permission denials are represented by RejectedError, which includes context +// about the denied operation: +// +// if err != nil && IsRejectedError(err) { +// rejErr := err.(*RejectedError) +// log.Printf("Permission denied for %s: %s", rejErr.Type, rejErr.Message) +// } +// +// # Event Integration +// +// The permission system integrates with the event system to notify UI components +// about permission requests and responses. This enables real-time user interaction +// through web interfaces or other UI systems. +// +// # Security Considerations +// +// The permission system is designed with security in mind: +// - All bash commands are parsed and validated +// - Pattern matching prevents bypass through command variations +// - Doom loop detection prevents resource exhaustion +// - Session isolation prevents permission escalation across sessions +// - External directory access is explicitly controlled +// +// # Thread Safety +// +// All components in this package are thread-safe and can be used concurrently +// across multiple goroutines handling different user sessions. +package permission \ No newline at end of file diff --git a/go-opencode/internal/provider/doc.go b/go-opencode/internal/provider/doc.go new file mode 100644 index 00000000000..a027a176830 --- /dev/null +++ b/go-opencode/internal/provider/doc.go @@ -0,0 +1,151 @@ +// Package provider provides LLM provider abstraction layer for OpenCode. +// +// This package implements a unified interface for different Large Language Model +// providers using the Eino framework. It supports multiple providers including +// Anthropic Claude, OpenAI GPT, and Volcengine ARK models. +// +// # Core Components +// +// The package is built around several key interfaces and types: +// +// - Provider: Core interface that all LLM providers must implement +// - Registry: Manages and coordinates multiple providers +// - CompletionRequest/CompletionStream: Handles streaming chat completions +// - Tool conversion utilities for function calling +// +// # Supported Providers +// +// ## Anthropic (Claude) +// +// Supports Claude models including Claude 4 Sonnet, Claude 4 Opus, and Claude 3.5 series. +// Features include: +// +// - Direct API access or AWS Bedrock integration +// +// - Extended thinking support for reasoning tasks +// +// - Prompt caching for improved performance +// +// - Vision and tool calling capabilities +// +// provider, err := NewAnthropicProvider(ctx, &AnthropicConfig{ +// ID: "anthropic", +// APIKey: "sk-...", +// Model: "claude-sonnet-4-20250514", +// MaxTokens: 8192, +// }) +// +// ## OpenAI (GPT) +// +// Supports OpenAI models and OpenAI-compatible endpoints including: +// +// - Native OpenAI API access +// +// - Azure OpenAI Service +// +// - Local and self-hosted OpenAI-compatible servers +// +// provider, err := NewOpenAIProvider(ctx, &OpenAIConfig{ +// ID: "openai", +// APIKey: "sk-...", +// Model: "gpt-4o", +// MaxTokens: 4096, +// }) +// +// ## Volcengine ARK +// +// Supports Volcengine's ARK platform for accessing Chinese language models: +// +// provider, err := NewArkProvider(ctx, &ArkConfig{ +// APIKey: "...", +// Model: "endpoint-id", +// MaxTokens: 4096, +// }) +// +// # Registry Usage +// +// The Registry manages all configured providers and provides unified access: +// +// registry := NewRegistry(config) +// +// // Get a specific provider +// provider, err := registry.Get("anthropic") +// +// // Get a specific model +// model, err := registry.GetModel("anthropic", "claude-sonnet-4-20250514") +// +// // Get default model based on configuration +// model, err := registry.DefaultModel() +// +// // List all available models across providers +// models := registry.AllModels() +// +// # Configuration +// +// Providers can be configured through: +// +// 1. Configuration file with provider sections +// 2. Environment variables (auto-discovery) +// 3. Programmatic registration +// +// Configuration supports npm package mapping for TypeScript compatibility: +// +// [provider.anthropic] +// npm = "@ai-sdk/anthropic" +// model = "claude-sonnet-4-20250514" +// [provider.anthropic.options] +// apiKey = "sk-..." +// +// # Streaming Completions +// +// All providers support streaming chat completions through a unified interface: +// +// stream, err := provider.CreateCompletion(ctx, &CompletionRequest{ +// Model: "claude-sonnet-4-20250514", +// Messages: messages, +// Tools: tools, +// MaxTokens: 4096, +// }) +// +// for { +// msg, err := stream.Recv() +// if err != nil { +// break +// } +// // Process message chunk +// } +// stream.Close() +// +// # Tool Calling +// +// The package provides utilities for converting between different tool calling formats: +// +// // Convert internal tool definitions to Eino format +// einoTools := ConvertToEinoTools(tools) +// +// // Convert messages between formats +// einoMessages := ConvertToEinoMessages(messages, parts) +// +// # Error Handling +// +// The package uses Go's standard error handling patterns. Common error scenarios: +// - Missing API keys or credentials +// - Invalid model configurations +// - Network connectivity issues +// - Provider-specific API errors +// +// Most functions return meaningful error messages that can be used for debugging +// and user feedback. +// +// # Integration with Eino +// +// This package is built on top of the Eino framework (https://github.com/cloudwego/eino), +// which provides: +// - Standardized LLM interfaces +// - Built-in tool calling support +// - Streaming capabilities +// - Message schema definitions +// +// The abstraction allows OpenCode to support multiple providers through a single, +// consistent interface while leveraging Eino's robust foundation. +package provider diff --git a/go-opencode/internal/server/doc.go b/go-opencode/internal/server/doc.go new file mode 100644 index 00000000000..f9f30d18dcf --- /dev/null +++ b/go-opencode/internal/server/doc.go @@ -0,0 +1,109 @@ +// Package server provides the HTTP server implementation for the OpenCode API. +// +// The server package implements a comprehensive RESTful API server that serves as the +// backbone of the OpenCode application. It provides endpoints for managing AI-powered +// coding sessions, file operations, configuration management, and real-time event streaming. +// +// # Core Components +// +// The server is built around several key components: +// +// - HTTP Server: Chi-based router with middleware for CORS, logging, and recovery +// - Session Management: Handles AI conversation sessions with providers +// - Event Streaming: Server-Sent Events (SSE) for real-time updates +// - File Operations: File system operations and Git integration +// - Provider Integration: Support for multiple AI providers (Anthropic, OpenAI, etc.) +// - Tool Registry: Extensible tool system for AI capabilities +// - MCP Integration: Model Context Protocol support for external tools +// - LSP Integration: Language Server Protocol for code intelligence +// +// # API Endpoints +// +// The server exposes the following main endpoint categories: +// +// - /session/*: Session lifecycle management and messaging +// - /file/*: File system operations and Git status +// - /config/*: Application configuration management +// - /provider/*: AI provider management and authentication +// - /event: Real-time event streaming via SSE +// - /mcp/*: Model Context Protocol server management +// - /tui/*: Terminal UI control endpoints +// - /client-tools/*: External tool registration and execution +// +// # Session Management +// +// Sessions are the core abstraction for AI conversations. Each session: +// - Maintains conversation history with an AI provider +// - Has an associated working directory for file operations +// - Can be forked to create branching conversations +// - Supports real-time streaming of AI responses +// - Integrates with tools for code analysis and modification +// +// # Event System +// +// The server implements a custom SSE-based event system for real-time updates: +// - Session events (message updates, status changes) +// - File system events (changes, Git status updates) +// - Tool execution events +// - Provider status updates +// +// # Tool Integration +// +// The server supports multiple tool systems: +// - Built-in tools for file operations, shell commands, and code formatting +// - MCP (Model Context Protocol) servers for external tool integration +// - Client-registered tools for custom functionality +// - LSP integration for code intelligence and symbol search +// +// # Configuration +// +// Server configuration is managed through: +// - Static configuration file (types.Config) +// - Runtime configuration updates via API +// - Environment-based provider authentication +// - Per-project settings and preferences +// +// # Usage Example +// +// config := server.DefaultConfig() +// config.Port = 8080 +// config.Directory = "/path/to/project" +// +// srv := server.New(config, appConfig, storage, providerRegistry, toolRegistry) +// +// // Initialize MCP servers +// if err := srv.InitializeMCP(ctx); err != nil { +// log.Fatal(err) +// } +// defer srv.CloseMCP() +// +// // Start server +// if err := srv.Start(); err != nil { +// log.Fatal(err) +// } +// +// # Architecture Notes +// +// The server uses a layered architecture: +// - HTTP handlers for request/response processing +// - Service layer for business logic (session, storage, etc.) +// - Provider abstraction for AI model integration +// - Event bus for decoupled component communication +// - Storage layer for persistence +// +// The implementation favors composition over inheritance, with each major +// component (sessions, tools, providers) being independently testable +// and replaceable. +// +// # SSE Implementation +// +// The server includes a custom Server-Sent Events implementation optimized +// for the OpenCode use case. This provides real-time streaming of: +// - AI response tokens as they're generated +// - Tool execution progress and results +// - File system change notifications +// - Session status updates +// +// The SSE implementation includes heartbeat support, proper error handling, +// and session-based event filtering for efficient client updates. +package server \ No newline at end of file diff --git a/go-opencode/internal/server/handlers_message.go b/go-opencode/internal/server/handlers_message.go index eacc293b4fe..b4cfc9c4294 100644 --- a/go-opencode/internal/server/handlers_message.go +++ b/go-opencode/internal/server/handlers_message.go @@ -139,13 +139,13 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { } // Publish user message via SSE (SDK compatible: uses message.updated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageUpdated, Data: event.MessageUpdatedData{Info: userMsg}, }) // Publish user message parts (SDK compatible: uses message.part.updated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: textPart, @@ -154,7 +154,7 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { // Publish file parts if any for i := range req.Files { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: &req.Files[i], @@ -167,15 +167,10 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { // Updates are published via SSE, not streamed in HTTP response // IMPORTANT: Use background context for LLM processing to avoid cancellation // when the HTTP request completes. The LLM call can take seconds/minutes. - if req.Model != nil { - fmt.Printf("[message] Processing with provider=%s model=%s\n", req.Model.ProviderID, req.Model.ModelID) - } else { - fmt.Printf("[message] Processing with no model specified\n") - } llmCtx := context.Background() assistantMsg, parts, err := s.sessionService.ProcessMessage(llmCtx, session, content, req.Model, func(msg *types.Message, parts []types.Part) { // Publish updates via SSE - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: "message.updated", Data: event.MessageUpdatedData{Info: msg}, }) @@ -225,7 +220,7 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { }) // Publish session.error event via SSE - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: "session.error", Data: event.SessionErrorData{ SessionID: sessionID, diff --git a/go-opencode/internal/server/handlers_session.go b/go-opencode/internal/server/handlers_session.go index dab5ab6bac7..d11037fc33b 100644 --- a/go-opencode/internal/server/handlers_session.go +++ b/go-opencode/internal/server/handlers_session.go @@ -67,7 +67,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { } // Publish event (SDK compatible: uses "info" field) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionCreated, Data: event.SessionCreatedData{Info: session}, }) @@ -105,7 +105,7 @@ func (s *Server) updateSession(w http.ResponseWriter, r *http.Request) { } // Publish event (SDK compatible: uses "info" field) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionUpdated, Data: event.SessionUpdatedData{Info: session}, }) @@ -126,7 +126,7 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { } // Publish event (SDK compatible: uses "info" field with full session) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionDeleted, Data: event.SessionDeletedData{Info: session}, }) @@ -190,7 +190,7 @@ func (s *Server) forkSession(w http.ResponseWriter, r *http.Request) { } // Publish event (SDK compatible: uses "info" field) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionCreated, Data: event.SessionCreatedData{Info: newSession}, }) @@ -428,7 +428,7 @@ func (s *Server) respondPermission(w http.ResponseWriter, r *http.Request) { } // Publish event (SDK compatible: uses PermissionReplied) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.PermissionReplied, Data: event.PermissionRepliedData{ PermissionID: permissionID, diff --git a/go-opencode/internal/server/sse.go b/go-opencode/internal/server/sse.go index 611820a3950..208cdf37f76 100644 --- a/go-opencode/internal/server/sse.go +++ b/go-opencode/internal/server/sse.go @@ -17,15 +17,12 @@ import ( "encoding/json" "fmt" "net/http" - "sync/atomic" "time" "github.com/opencode-ai/opencode/internal/event" + "github.com/opencode-ai/opencode/internal/logging" ) -// SSE event counter for debugging -var sseEventCounter uint64 - // SDKEvent represents an SDK-compatible event with proper JSON field ordering. // TypeScript expects: {"type": "...", "properties": {...}} type SDKEvent struct { @@ -66,32 +63,11 @@ func (s *sseWriter) writeEvent(eventType string, data any) error { return err } - // Log SSE event for debugging - include event type from data if available - count := atomic.AddUint64(&sseEventCounter, 1) - dataType := "" - switch d := data.(type) { - case SDKEvent: - dataType = string(d.Type) - case map[string]any: - // Handle both string and event.EventType (which is a string alias) - switch t := d["type"].(type) { - case string: - dataType = t - case event.EventType: - dataType = string(t) - } - } - - t1 := time.Now() - fmt.Printf("[sse] #%d PRE-WRITE event=%s dataType=%s time=%s\n", - count, eventType, dataType, t1.Format("15:04:05.000")) - // Write SSE format: event type, data, and blank line _, err = fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", eventType, jsonData) if err != nil { return err } - t2 := time.Now() // Flush immediately using ResponseController (more reliable than Flusher interface) // This ensures data is sent even through middleware wrappers @@ -99,10 +75,6 @@ func (s *sseWriter) writeEvent(eventType string, data any) error { // Fallback to traditional flusher s.flusher.Flush() } - t3 := time.Now() - - fmt.Printf("[sse] #%d POST-FLUSH event=%s dataType=%s write=%v flush=%v total=%v\n", - count, eventType, dataType, t2.Sub(t1), t3.Sub(t2), t3.Sub(t1)) return nil } @@ -116,7 +88,6 @@ func (s *sseWriter) writeHeartbeat() { // allEvents handles SSE for all events (used by /event endpoint). // This is the main event endpoint that the TUI connects to. func (srv *Server) allEvents(w http.ResponseWriter, r *http.Request) { - fmt.Printf("[sse] allEvents: new connection from %s\n", r.RemoteAddr) // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -146,15 +117,13 @@ func (srv *Server) allEvents(w http.ResponseWriter, r *http.Request) { events := make(chan event.Event, 10) // Subscribe to all events - var recvCounter uint64 unsub := event.SubscribeAll(func(e event.Event) { - count := atomic.AddUint64(&recvCounter, 1) - fmt.Printf("[sse] #%d recv type=%s time=%s\n", - count, e.Type, time.Now().Format("15:04:05.000")) select { case events <- e: default: - fmt.Printf("[sse] #%d DROPPED (channel full) type=%s\n", count, e.Type) + logging.Warn(). + Str("eventType", string(e.Type)). + Msg("SSE event dropped: channel full") } }) defer unsub() @@ -163,23 +132,18 @@ func (srv *Server) allEvents(w http.ResponseWriter, r *http.Request) { ticker := time.NewTicker(SSEHeartbeatInterval) defer ticker.Stop() - fmt.Printf("[sse] allEvents: entering select loop, channel len=%d\n", len(events)) - // Wait for client disconnect or context cancellation for { select { case <-r.Context().Done(): - fmt.Printf("[sse] allEvents: context done\n") return case e := <-events: - fmt.Printf("[sse] allEvents: got event from channel type=%s\n", e.Type) // SDK compatible format: use struct for proper field ordering data := SDKEvent{ Type: e.Type, Properties: e.Data, } if err := sse.writeEvent("message", data); err != nil { - fmt.Printf("[sse] allEvents: write error: %v\n", err) return } case <-ticker.C: @@ -190,7 +154,6 @@ func (srv *Server) allEvents(w http.ResponseWriter, r *http.Request) { // globalEvents handles SSE for all events. func (srv *Server) globalEvents(w http.ResponseWriter, r *http.Request) { - fmt.Printf("[sse] globalEvents: new connection from %s\n", r.RemoteAddr) // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -212,15 +175,13 @@ func (srv *Server) globalEvents(w http.ResponseWriter, r *http.Request) { events := make(chan event.Event, 10) // Subscribe to all events - var recvCounter uint64 unsub := event.SubscribeAll(func(e event.Event) { - count := atomic.AddUint64(&recvCounter, 1) - fmt.Printf("[sse-global] #%d recv type=%s time=%s\n", - count, e.Type, time.Now().Format("15:04:05.000")) select { case events <- e: default: - fmt.Printf("[sse-global] #%d DROPPED (channel full) type=%s\n", count, e.Type) + logging.Warn(). + Str("eventType", string(e.Type)). + Msg("SSE global event dropped: channel full") } }) defer unsub() @@ -283,7 +244,10 @@ func (srv *Server) sessionEvents(w http.ResponseWriter, r *http.Request) { select { case events <- e: default: - // Drop event if channel is full + logging.Warn(). + Str("eventType", string(e.Type)). + Str("sessionID", sessionID). + Msg("SSE session event dropped: channel full") } } }) diff --git a/go-opencode/internal/session/PROMPT.md b/go-opencode/internal/session/PROMPT.md new file mode 100644 index 00000000000..645e26ff50f --- /dev/null +++ b/go-opencode/internal/session/PROMPT.md @@ -0,0 +1,181 @@ +# Session Package + +The session package handles LLM conversation sessions, message processing, and streaming responses. + +## Key Components + +| File | Description | +|------|-------------| +| `service.go` | Session service interface and implementation | +| `processor.go` | Message processing and LLM interaction | +| `stream.go` | Stream processing for LLM responses | +| `loop.go` | Main processing loop with tool execution | +| `tools.go` | Tool execution and result handling | +| `compact.go` | Context compaction/summarization | +| `title.go` | Session title generation from first message | + +## Token Usage Tracking + +### Overview + +The session package tracks token usage for each LLM response to display context window usage in the TUI sidebar. The TUI calculates context as: + +``` +total = input + output + reasoning + cache.read + cache.write +``` + +### Eino Framework Streaming Behavior + +The Eino claude framework (`cloudwego/eino-ext/components/model/claude`) sends token usage across multiple stream events: + +| Event | Token Data | +|-------|-----------| +| `MessageStartEvent` (first) | `PromptTokens` (input tokens + cache info) | +| `MessageDeltaEvent` (last) | `CompletionTokens` only | + +**Important**: `MessageStartEvent` contains: +```go +promptTokens := int(resp.Usage.InputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens) +``` + +While `MessageDeltaEvent` only contains: +```go +Usage: &schema.TokenUsage{ + CompletionTokens: int(e.Usage.OutputTokens), +} +``` + +### Implementation in stream.go + +Because token usage is split across multiple stream events, we **merge** the values by taking the maximum of each field: + +```go +// Track token usage across stream events +var inputTokens, completionTokens, cachedTokens int +var hasUsage bool + +// In stream loop: +if msg.ResponseMeta != nil && msg.ResponseMeta.Usage != nil { + usage := msg.ResponseMeta.Usage + hasUsage = true + // Take the max of each field (first event has input, last has output) + if usage.PromptTokens > inputTokens { + inputTokens = usage.PromptTokens + } + if usage.CompletionTokens > completionTokens { + completionTokens = usage.CompletionTokens + } + if usage.PromptTokenDetails.CachedTokens > cachedTokens { + cachedTokens = usage.PromptTokenDetails.CachedTokens + } +} +``` + +This ensures: +- `inputTokens` comes from `MessageStartEvent` +- `completionTokens` comes from `MessageDeltaEvent` +- `cachedTokens` comes from `MessageStartEvent` + +### Common Pitfalls + +**DO NOT** simply keep the "latest" usage value: +```go +// WRONG: This loses input tokens from the first event +if msg.ResponseMeta != nil && msg.ResponseMeta.Usage != nil { + finalUsage = msg.ResponseMeta.Usage // Overwrites good data! +} +``` + +This will result in `input=0` and `cache.read=0` because the last event (`MessageDeltaEvent`) doesn't include those fields. + +## Event Publishing + +The session package publishes events via the `event.PublishSync` function. Events are published for: + +- Message creation (`message.created`) +- Message updates (`message.updated`) +- Part updates (`message.part.updated`) +- Session status changes (`session.status`) +- Session compaction (`session.compacted`) + +### SSE Subscriber Requirements + +When using `PublishSync`, subscribers are called synchronously. SSE subscribers must use non-blocking channel sends to avoid deadlocks: + +```go +select { +case events <- e: +default: + logging.Warn(). + Str("eventType", string(e.Type)). + Msg("SSE event dropped: channel full") +} +``` + +## Finish Reason Normalization + +Different providers return different finish reasons. The session package normalizes them to SDK-compatible format: + +| Provider Value | Normalized Value | +|---------------|------------------| +| `tool_use` | `tool-calls` | +| `stop` | `stop` | +| (empty with tool calls) | `tool-calls` | +| (empty without tool calls) | `stop` | + +## Step Parts + +Each LLM inference step is bracketed with parts: + +1. **step-start**: Emitted at the beginning of inference +2. **text/tool/reasoning parts**: Streamed content +3. **step-finish**: Emitted at the end with cost and token info + +The `step-finish` part includes the final token usage and finish reason, which the TUI uses for context display. + +## Session Title Generation + +### Overview + +Session titles are automatically generated on the first user message to provide meaningful names in the TUI sidebar (instead of "New Session"). + +### Implementation in title.go + +The `ensureTitle` function: +1. Only runs on first user message (step == 0) +2. Checks if session has no parent AND title is still default ("New Session") +3. Uses the default model to generate a brief title (≤50 chars) +4. Runs asynchronously (goroutine) to not block the response +5. Publishes `session.updated` event so TUI updates immediately + +### Title Generation Prompt + +The system prompt instructs the LLM to: +- Output ONLY a thread title (single line, ≤50 characters) +- Use -ing verbs for actions (Debugging, Implementing, Analyzing) +- Keep technical terms, numbers, filenames exact +- Remove articles (the, this, my, a, an) + +### When Title is Generated + +In `loop.go`, after the first step (step == 0) completes successfully: + +```go +if step == 0 && userContent != "" { + go p.ensureTitle(context.Background(), &session, userContent) +} +``` + +The title generation uses `context.Background()` because: +- It runs in a goroutine that may outlive the HTTP request +- Title generation is non-critical and should not be cancelled + +### Default Title Detection + +A title is considered "default" if it equals "New Session" or starts with "New Session": + +```go +func isDefaultTitle(title string) bool { + return title == defaultTitlePrefix || strings.HasPrefix(title, defaultTitlePrefix) +} +``` diff --git a/go-opencode/internal/session/compact.go b/go-opencode/internal/session/compact.go index 7a90e611008..9d36763ca75 100644 --- a/go-opencode/internal/session/compact.go +++ b/go-opencode/internal/session/compact.go @@ -204,8 +204,6 @@ func (p *Processor) processCompaction( compactionPart *types.CompactionPart, callback ProcessCallback, ) error { - fmt.Printf("[compact] Processing compaction for session %s\n", sessionID) - // Find session session, err := p.findSession(ctx, sessionID) if err != nil { @@ -276,7 +274,7 @@ func (p *Processor) processCompaction( callback(assistantMsg, nil) // Publish message created event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageCreated, Data: event.MessageCreatedData{Info: assistantMsg}, }) @@ -296,7 +294,7 @@ func (p *Processor) processCompaction( } // Publish part created event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{Part: textPart}, }) @@ -340,7 +338,7 @@ func (p *Processor) processCompaction( p.storage.Put(ctx, []string{"part", assistantMsg.ID, textPart.ID}, textPart) // Publish streaming update with delta - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: textPart, @@ -358,19 +356,17 @@ func (p *Processor) processCompaction( p.storage.Put(ctx, []string{"message", sessionID, assistantMsg.ID}, assistantMsg) // Publish message updated event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageUpdated, Data: event.MessageUpdatedData{Info: assistantMsg}, }) // Publish session.compacted event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionCompacted, Data: event.SessionCompactedData{SessionID: sessionID}, }) - fmt.Printf("[compact] Compaction complete for session %s\n", sessionID) - // If auto-compaction, add a "Continue if you have next steps" message if compactionPart.Auto { continueMsg := &types.Message{ @@ -394,11 +390,11 @@ func (p *Processor) processCompaction( } p.storage.Put(ctx, []string{"part", continueMsg.ID, continuePart.ID}, continuePart) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageCreated, Data: event.MessageCreatedData{Info: continueMsg}, }) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{Part: continuePart}, }) diff --git a/go-opencode/internal/session/doc.go b/go-opencode/internal/session/doc.go new file mode 100644 index 00000000000..8ce94d1872a --- /dev/null +++ b/go-opencode/internal/session/doc.go @@ -0,0 +1,233 @@ +// Package session provides comprehensive session management functionality for the OpenCode AI assistant. +// +// This package implements the core session lifecycle, message processing, and agentic loop +// that powers OpenCode's AI-driven code assistance capabilities. It manages conversations +// between users and AI agents, handles tool execution, and maintains session state across +// multiple interactions. +// +// # Architecture Overview +// +// The session package is built around several key components: +// +// - Service: High-level session management and CRUD operations +// - Processor: Core agentic loop implementation with streaming LLM interactions +// - Agent: Configurable AI agent profiles with different capabilities and permissions +// - Tools: Integration with the tool registry for code manipulation and execution +// - Storage: Persistent storage of sessions, messages, and conversation history +// +// # Core Components +// +// ## Service +// +// The Service struct provides the main API for session management: +// +// service := session.NewService(storage) +// +// // Create a new session +// sess, err := service.Create(ctx, "/path/to/project", "My Session") +// +// // Process user messages +// msg, parts, err := service.ProcessMessage(ctx, sess, "Help me refactor this code", model, callback) +// +// ## Processor +// +// The Processor handles the agentic loop - the core AI reasoning cycle: +// +// processor := session.NewProcessor(providerReg, toolReg, storage, permChecker, "anthropic", "claude-sonnet") +// err := processor.Process(ctx, sessionID, agent, callback) +// +// The processor manages: +// - LLM streaming and response processing +// - Tool call execution with permission checking +// - Context management and compaction +// - Error handling and retries with exponential backoff +// - Real-time event publishing for UI updates +// +// ## Agents +// +// Agents define AI behavior profiles with different capabilities: +// +// // Default general-purpose agent +// agent := session.DefaultAgent() +// +// // Code-focused agent with write permissions +// codeAgent := session.CodeAgent() +// +// // Planning agent without file modification capabilities +// planAgent := session.PlanAgent() +// +// Agent configuration includes: +// - System prompts and personality +// - Temperature and sampling parameters +// - Tool access permissions +// - Safety policies (doom loop detection, permission requirements) +// +// # Message Processing Flow +// +// The typical message processing flow follows these steps: +// +// 1. User creates a message with text/file parts +// 2. Service.ProcessMessage() initiates the agentic loop +// 3. Processor loads conversation history and builds LLM context +// 4. System prompt is constructed based on agent configuration +// 5. LLM generates streaming response with potential tool calls +// 6. Tools are executed with permission checking +// 7. Results are fed back to the LLM for continued reasoning +// 8. Process repeats until completion or step limit reached +// 9. Final response is saved and events published +// +// # Tool Integration +// +// The session package integrates tightly with the tool system: +// +// // Tools are called by the LLM during processing +// toolPart := &types.ToolPart{ +// Tool: "write_file", +// State: types.ToolState{ +// Input: map[string]any{ +// "path": "main.go", +// "content": "package main...", +// }, +// }, +// } +// +// Tool execution includes: +// - Permission validation based on agent policies +// - Doom loop detection for repeated identical calls +// - Real-time progress updates via callbacks +// - Error handling and graceful degradation +// +// # Context Management +// +// The package implements intelligent context management: +// +// - Automatic message compaction when context limits are approached +// - Conversation summarization to preserve key information +// - Token counting and optimization +// - Configurable retention policies +// +// # Event System +// +// Real-time events are published throughout the processing lifecycle: +// +// // Session status updates +// event.SessionStatus{SessionID: "...", Status: "busy"} +// +// // Message creation and updates +// event.MessageCreated{Info: message} +// event.MessagePartUpdated{Part: part} +// +// // Session completion +// event.SessionIdle{SessionID: "..."} +// +// # Permission System +// +// Fine-grained permission control is enforced: +// +// - Tool-level permissions (allow/deny/ask) +// - File system access controls +// - Shell command execution policies +// - Doom loop prevention +// +// # Storage and Persistence +// +// Sessions and messages are persisted using a hierarchical key-value structure: +// +// session/{projectID}/{sessionID} -> Session metadata +// message/{sessionID}/{messageID} -> Individual messages +// part/{messageID}/{partID} -> Message parts (text, files, tools) +// +// # Error Handling +// +// Robust error handling is implemented throughout: +// +// - Exponential backoff for LLM API failures +// - Graceful degradation when tools fail +// - Context cancellation support +// - Detailed error propagation and logging +// +// # Usage Examples +// +// ## Basic Session Creation +// +// service := session.NewServiceWithProcessor( +// storage, providerReg, toolReg, permChecker, +// "anthropic", "claude-sonnet-4-20250514", +// ) +// +// sess, err := service.Create(ctx, "/home/user/project", "Code Review") +// if err != nil { +// log.Fatal(err) +// } +// +// ## Processing User Input +// +// callback := func(msg *types.Message, parts []types.Part) { +// // Handle real-time updates +// fmt.Printf("Response: %v\n", parts) +// } +// +// model := &types.ModelRef{ +// ProviderID: "anthropic", +// ModelID: "claude-sonnet-4-20250514", +// } +// +// msg, parts, err := service.ProcessMessage(ctx, sess, "Refactor this function", model, callback) +// +// ## Custom Agent Configuration +// +// agent := &session.Agent{ +// Name: "security-reviewer", +// Temperature: 0.2, +// MaxSteps: 20, +// Prompt: "You are a security-focused code reviewer...", +// Tools: []string{"read", "grep"}, // Read-only tools +// Permission: session.AgentPermission{ +// Write: "deny", +// Bash: "deny", +// }, +// } +// +// ## Session Management +// +// // List sessions for a project +// sessions, err := service.List(ctx, "/home/user/project") +// +// // Fork a session at a specific message +// fork, err := service.Fork(ctx, sessionID, messageID) +// +// // Share a session +// shareURL, err := service.Share(ctx, sessionID) +// +// // Abort active processing +// err = service.Abort(ctx, sessionID) +// +// # Thread Safety +// +// The session package is designed for concurrent use: +// - Service methods are thread-safe +// - Processor handles concurrent session processing +// - Proper synchronization prevents race conditions +// - Context cancellation is respected throughout +// +// # Performance Considerations +// +// - Streaming responses minimize latency +// - Context compaction prevents memory bloat +// - Efficient storage access patterns +// - Configurable retry policies balance reliability and speed +// +// # Integration Points +// +// The session package integrates with several other OpenCode components: +// +// - internal/provider: LLM provider abstraction +// - internal/tool: Tool execution framework +// - internal/storage: Persistent data storage +// - internal/permission: Access control and security +// - internal/event: Real-time event system +// - pkg/types: Shared type definitions +// +// This package forms the core of OpenCode's conversational AI capabilities, +// providing a robust foundation for AI-assisted software development workflows. +package session \ No newline at end of file diff --git a/go-opencode/internal/session/loop.go b/go-opencode/internal/session/loop.go index ffac6ab0eca..aaee056a092 100644 --- a/go-opencode/internal/session/loop.go +++ b/go-opencode/internal/session/loop.go @@ -66,13 +66,13 @@ func (p *Processor) runLoop( } // Emit initial session.updated event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionUpdated, Data: event.SessionUpdatedData{Info: &session}, }) // Emit initial session.diff event (empty diffs at start) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionDiff, Data: event.SessionDiffData{ SessionID: sessionID, @@ -91,22 +91,16 @@ func (p *Processor) runLoop( } lastMsg := messages[len(messages)-1] - fmt.Printf("[loop] Loaded %d messages, last message role=%s id=%s\n", len(messages), lastMsg.Role, lastMsg.ID) if lastMsg.Role != "user" { return fmt.Errorf("expected user message, got %s", lastMsg.Role) } - // Load and log user message parts + // Load user message parts userParts, _ := p.loadParts(ctx, lastMsg.ID) var compactionPart *types.CompactionPart - for i, part := range userParts { - switch pt := part.(type) { - case *types.TextPart: - fmt.Printf("[loop] User message part %d: type=text content=%q\n", i, truncateStr(pt.Text, 50)) - case *types.CompactionPart: - fmt.Printf("[loop] User message part %d: type=compaction auto=%v\n", i, pt.Auto) + for _, part := range userParts { + if pt, ok := part.(*types.CompactionPart); ok { compactionPart = pt - default: - fmt.Printf("[loop] User message part %d: type=%T\n", i, pt) + break } } @@ -124,13 +118,10 @@ func (p *Processor) runLoop( modelID = lastMsg.Model.ModelID } - fmt.Printf("[loop] Looking up provider=%s model=%s\n", providerID, modelID) prov, err := p.providerRegistry.Get(providerID) if err != nil { - fmt.Printf("[loop] Provider lookup failed: %v\n", err) return fmt.Errorf("provider not found: %w", err) } - fmt.Printf("[loop] Found provider: %s\n", prov.ID()) model, err := p.providerRegistry.GetModel(providerID, modelID) if err != nil { @@ -173,7 +164,7 @@ func (p *Processor) runLoop( callback(assistantMsg, nil) // Publish event (SDK compatible: uses "info" field) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageCreated, Data: event.MessageCreatedData{Info: assistantMsg}, }) @@ -187,6 +178,15 @@ func (p *Processor) runLoop( step := 0 retryBackoff := newRetryBackoff(ctx) + // Get user content from first message for title generation + userContent := "" + for _, part := range userParts { + if textPart, ok := part.(*types.TextPart); ok { + userContent = textPart.Text + break + } + } + for { // Check context cancellation select { @@ -218,7 +218,6 @@ func (p *Processor) runLoop( if err != nil { return fmt.Errorf("failed to reload messages: %w", err) } - fmt.Printf("[loop] Reloaded %d messages for step %d\n", len(messages), step) // Build completion request req, err := p.buildCompletionRequest(ctx, sessionID, messages, assistantMsg, agent, model) @@ -252,10 +251,8 @@ func (p *Processor) runLoop( requestStart := time.Now() // Call LLM with streaming - fmt.Printf("[loop] Calling CreateCompletion...\n") stream, err := prov.CreateCompletion(ctx, req) if err != nil { - fmt.Printf("[loop] CreateCompletion error: %v\n", err) // Use exponential backoff with jitter for retries nextInterval := retryBackoff.NextBackOff() if nextInterval == backoff.Stop { @@ -266,11 +263,9 @@ func (p *Processor) runLoop( time.Sleep(nextInterval) continue } - fmt.Printf("[loop] CreateCompletion successful, processing stream...\n") // Process stream finishReason, err := p.processStream(ctx, stream, state, callback) - fmt.Printf("[loop] processStream returned: finishReason=%s, err=%v\n", finishReason, err) stream.Close() requestDuration := time.Since(requestStart) @@ -335,6 +330,11 @@ func (p *Processor) runLoop( state.message.Tokens = &types.TokenUsage{Input: 0, Output: 0} } + // Generate title on first step completion (async, don't block) + if step == 0 && userContent != "" { + go p.ensureTitle(context.Background(), &session, userContent) + } + // Check finish reason switch finishReason { case "stop", "end_turn": @@ -347,13 +347,10 @@ func (p *Processor) runLoop( case "tool_use", "tool_calls", "tool-calls": // Execute tools and continue loop // Note: "tool-calls" is SDK compatible (TypeScript), "tool_use" is from some providers - fmt.Printf("[loop] Got tool_use/tool_calls/tool-calls, calling executeToolCalls with %d parts\n", len(state.parts)) if err := p.executeToolCalls(ctx, state, agent, callback); err != nil { - fmt.Printf("[loop] executeToolCalls returned error: %v\n", err) // Tool execution errors don't stop the loop // The error is captured in the tool part } - fmt.Printf("[loop] executeToolCalls completed, step=%d\n", step) step++ continue @@ -424,7 +421,7 @@ func (p *Processor) saveMessage(ctx context.Context, sessionID string, msg *type } // Publish event (SDK compatible: uses "info" field) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageUpdated, Data: event.MessageUpdatedData{Info: msg}, }) @@ -470,42 +467,38 @@ func (p *Processor) buildCompletionRequest( Content: systemPrompt.Build(), }) - fmt.Printf("[build] Processing %d messages for completion request\n", len(messages)) - // Add conversation history for _, msg := range messages { - fmt.Printf("[build] Message: role=%s, id=%s\n", msg.Role, msg.ID) - // Skip errored messages without content if msg.Error != nil && !p.hasUsableContent(ctx, msg) { - fmt.Printf("[build] Skipping errored message without content\n") continue } // Load parts for this message parts, err := p.loadParts(ctx, msg.ID) if err != nil { - fmt.Printf("[build] Failed to load parts for message %s: %v\n", msg.ID, err) continue } - fmt.Printf("[build] Loaded %d parts for message %s\n", len(parts), msg.ID) // Skip messages with no parts (e.g., newly created empty assistant messages) if len(parts) == 0 { - fmt.Printf("[build] Skipping message %s with no parts\n", msg.ID) continue } einoMsg := p.convertMessage(msg, parts) + + // Skip messages that have no content and no tool calls + // (e.g., messages with only StepStartPart, StepFinishPart, etc.) + if einoMsg.Content == "" && len(einoMsg.ToolCalls) == 0 && einoMsg.ToolCallID == "" && einoMsg.ReasoningContent == "" { + continue + } + einoMessages = append(einoMessages, einoMsg) // For assistant messages with tool parts, add separate tool result messages if msg.Role == "assistant" { for _, part := range parts { if toolPart, ok := part.(*types.ToolPart); ok { - fmt.Printf("[build] Found ToolPart: tool=%s, status=%s, callID=%s, output=%q\n", - toolPart.Tool, toolPart.State.Status, toolPart.CallID, truncateStr(toolPart.State.Output, 100)) - // Only completed or errored tool parts should be added as results if toolPart.State.Status == "completed" || toolPart.State.Status == "error" { var toolContent string @@ -513,11 +506,11 @@ func (p *Processor) buildCompletionRequest( toolContent = toolPart.State.Output } else if toolPart.State.Error != "" { toolContent = "Error: " + toolPart.State.Error + } else { + // Anthropic API requires non-empty content for tool results + toolContent = "(no output)" } - fmt.Printf("[build] Adding tool result message for callID=%s, content length=%d\n", - toolPart.CallID, len(toolContent)) - toolMsg := &schema.Message{ Role: schema.Tool, Content: toolContent, @@ -594,10 +587,13 @@ func (p *Processor) convertMessage(msg *types.Message, parts []types.Part) *sche var toolCalls []schema.ToolCall var toolCallID string + var reasoningContent string for _, part := range parts { switch pt := part.(type) { case *types.TextPart: content += pt.Text + case *types.ReasoningPart: + reasoningContent += pt.Text case *types.ToolPart: if msg.Role == "assistant" { // For assistant messages, include all tool calls (even completed ones) @@ -624,9 +620,10 @@ func (p *Processor) convertMessage(msg *types.Message, parts []types.Part) *sche } einoMsg := &schema.Message{ - Role: role, - Content: content, - ToolCalls: toolCalls, + Role: role, + Content: content, + ToolCalls: toolCalls, + ReasoningContent: reasoningContent, } if toolCallID != "" { diff --git a/go-opencode/internal/session/loop_test.go b/go-opencode/internal/session/loop_test.go index 547891e4e32..98054205bbd 100644 --- a/go-opencode/internal/session/loop_test.go +++ b/go-opencode/internal/session/loop_test.go @@ -56,7 +56,7 @@ func TestAgenticLoopWithRealLLM(t *testing.T) { store := storage.New(tempDir) // Create processor - toolReg := tool.DefaultRegistry(tempDir) + toolReg := tool.DefaultRegistry(tempDir, store) permChecker := permission.NewChecker() processor := NewProcessor(providerReg, toolReg, store, permChecker, "ark", modelID) diff --git a/go-opencode/internal/session/processor.go b/go-opencode/internal/session/processor.go index 199f8c7af38..45f04ff8330 100644 --- a/go-opencode/internal/session/processor.go +++ b/go-opencode/internal/session/processor.go @@ -106,7 +106,7 @@ func (p *Processor) Process(ctx context.Context, sessionID string, agent *Agent, p.mu.Unlock() // Emit session.status busy event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionStatus, Data: event.SessionStatusData{ SessionID: sessionID, @@ -126,7 +126,7 @@ func (p *Processor) Process(ctx context.Context, sessionID string, agent *Agent, p.mu.Unlock() // Emit session.status idle event (SDK compatible: TUI uses this to stop progress bar) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionStatus, Data: event.SessionStatusData{ SessionID: sessionID, @@ -135,7 +135,7 @@ func (p *Processor) Process(ctx context.Context, sessionID string, agent *Agent, }) // Emit session.idle event when processing completes - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionIdle, Data: event.SessionIdleData{SessionID: sessionID}, }) diff --git a/go-opencode/internal/session/processor_test.go b/go-opencode/internal/session/processor_test.go index 523b6b8b805..8b052a90e96 100644 --- a/go-opencode/internal/session/processor_test.go +++ b/go-opencode/internal/session/processor_test.go @@ -15,7 +15,7 @@ import ( func TestNewProcessor(t *testing.T) { store := storage.New(t.TempDir()) - toolReg := tool.NewRegistry(t.TempDir()) + toolReg := tool.NewRegistry(t.TempDir(), store) proc := NewProcessor(nil, toolReg, store, nil, "", "") @@ -27,7 +27,7 @@ func TestNewProcessor(t *testing.T) { func TestProcessor_IsProcessing(t *testing.T) { store := storage.New(t.TempDir()) - toolReg := tool.NewRegistry(t.TempDir()) + toolReg := tool.NewRegistry(t.TempDir(), store) proc := NewProcessor(nil, toolReg, store, nil, "", "") // Initially not processing @@ -45,7 +45,7 @@ func TestProcessor_IsProcessing(t *testing.T) { func TestProcessor_Abort(t *testing.T) { store := storage.New(t.TempDir()) - toolReg := tool.NewRegistry(t.TempDir()) + toolReg := tool.NewRegistry(t.TempDir(), store) proc := NewProcessor(nil, toolReg, store, nil, "", "") // Try to abort non-existent session @@ -78,7 +78,7 @@ func TestProcessor_Abort(t *testing.T) { func TestProcessor_GetActiveState(t *testing.T) { store := storage.New(t.TempDir()) - toolReg := tool.NewRegistry(t.TempDir()) + toolReg := tool.NewRegistry(t.TempDir(), store) proc := NewProcessor(nil, toolReg, store, nil, "", "") // No active session diff --git a/go-opencode/internal/session/service.go b/go-opencode/internal/session/service.go index 697457e42e1..3220c7416ff 100644 --- a/go-opencode/internal/session/service.go +++ b/go-opencode/internal/session/service.go @@ -370,7 +370,7 @@ func (s *Service) Summarize(ctx context.Context, sessionID, providerID, modelID } // Publish message created event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessageCreated, Data: event.MessageCreatedData{Info: userMsg}, }) @@ -390,7 +390,7 @@ func (s *Service) Summarize(ctx context.Context, sessionID, providerID, modelID } // Publish part updated event - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{Part: compactionPart}, }) diff --git a/go-opencode/internal/session/stream.go b/go-opencode/internal/session/stream.go index 1d4f7cb53c6..a281755fcc0 100644 --- a/go-opencode/internal/session/stream.go +++ b/go-opencode/internal/session/stream.go @@ -3,7 +3,6 @@ package session import ( "context" "encoding/json" - "fmt" "io" "strings" "time" @@ -32,6 +31,14 @@ func (p *Processor) processStream( currentToolParts = make(map[string]*types.ToolPart) accumulatedToolInputs = make(map[string]string) + // Track token usage across stream events + // Eino claude framework sends usage in two events: + // - MessageStartEvent (first): contains PromptTokens and cache info + // - MessageDeltaEvent (last): contains CompletionTokens only + // We need to merge both to get complete usage. + var inputTokens, completionTokens, cachedTokens int + var hasUsage bool + // Emit step-start part at the beginning of inference stepStartPart := &types.StepStartPart{ ID: generatePartID(), @@ -41,48 +48,48 @@ func (p *Processor) processStream( } state.parts = append(state.parts, stepStartPart) p.savePart(ctx, state.message.ID, stepStartPart) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{Part: stepStartPart}, }) callback(state.message, state.parts) - fmt.Printf("[stream] Starting to receive chunks\n") - chunkCount := 0 - var lastChunkTime time.Time - var lastEventTime time.Time // For throttling event publishing - for { select { case <-ctx.Done(): - fmt.Printf("[stream] Context cancelled\n") return "error", ctx.Err() default: } msg, err := stream.Recv() if err == io.EOF { - fmt.Printf("[stream] Received EOF after %d chunks\n", chunkCount) break } if err != nil { - fmt.Printf("[stream] Error receiving chunk: %v\n", err) return "error", err } - chunkCount++ - now := time.Now() - var delta time.Duration - if !lastChunkTime.IsZero() { - delta = now.Sub(lastChunkTime) - } - lastChunkTime = now - fmt.Printf("[stream] Chunk %d (+%v): content=%q, toolCalls=%d, responseMeta=%v\n", - chunkCount, delta, truncate(msg.Content, 50), len(msg.ToolCalls), msg.ResponseMeta != nil) // Process the message chunk finishReason = p.processMessageChunk(ctx, msg, state, callback, ¤tTextPart, ¤tReasoningPart, currentToolParts, - &accumulatedContent, accumulatedToolInputs, &lastEventTime) + &accumulatedContent, accumulatedToolInputs) + + // Collect token usage - merge from different stream events + // MessageStartEvent has PromptTokens, MessageDeltaEvent has CompletionTokens + if msg.ResponseMeta != nil && msg.ResponseMeta.Usage != nil { + usage := msg.ResponseMeta.Usage + hasUsage = true + // Take the max of each field (first event has input, last has output) + if usage.PromptTokens > inputTokens { + inputTokens = usage.PromptTokens + } + if usage.CompletionTokens > completionTokens { + completionTokens = usage.CompletionTokens + } + if usage.PromptTokenDetails.CachedTokens > cachedTokens { + cachedTokens = usage.PromptTokenDetails.CachedTokens + } + } if finishReason != "" { break @@ -103,10 +110,7 @@ func (p *Processor) processStream( } // Finalize tool parts - fmt.Printf("[stream] Finalizing %d tool parts\n", len(currentToolParts)) for id, toolPart := range currentToolParts { - fmt.Printf("[stream] Finalizing toolPart: id=%s, tool=%s, callID=%s, currentStatus=%s\n", - id, toolPart.Tool, toolPart.CallID, toolPart.State.Status) if accInput, ok := accumulatedToolInputs[id]; ok && toolPart.State.Input == nil { var input map[string]any if err := json.Unmarshal([]byte(accInput), &input); err == nil { @@ -114,7 +118,6 @@ func (p *Processor) processStream( } } toolPart.State.Status = "running" - fmt.Printf("[stream] Set toolPart status to 'running': tool=%s, ptr=%p\n", toolPart.Tool, toolPart) p.savePart(ctx, state.message.ID, toolPart) } @@ -133,6 +136,16 @@ func (p *Processor) processStream( finishReason = "tool-calls" } + // Apply merged token usage after stream completes + if hasUsage { + if state.message.Tokens == nil { + state.message.Tokens = &types.TokenUsage{} + } + state.message.Tokens.Input = inputTokens + state.message.Tokens.Output = completionTokens + state.message.Tokens.Cache.Read = cachedTokens + } + // Emit step-finish part at the end of inference with cost and token info stepFinishPart := &types.StepFinishPart{ ID: generatePartID(), @@ -145,15 +158,12 @@ func (p *Processor) processStream( } state.parts = append(state.parts, stepFinishPart) p.savePart(ctx, state.message.ID, stepFinishPart) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{Part: stepFinishPart}, }) callback(state.message, state.parts) - fmt.Printf("[stream] Finished with reason=%s, parts=%d, tokens=%v\n", - finishReason, len(state.parts), state.message.Tokens) - return finishReason, nil } @@ -165,27 +175,6 @@ func truncate(s string, n int) string { return s[:n] + "..." } -// MinEventInterval is the minimum time between streaming events. -// This ensures the TUI has time to process each event before the next arrives. -// Set to slightly above TUI's 16ms batching window to prevent batching. -const MinEventInterval = 20 * time.Millisecond - -// throttledPublish publishes an event with optional throttling to prevent TUI batching. -func throttledPublish(e event.Event, lastEventTime *time.Time) { - if lastEventTime != nil && !lastEventTime.IsZero() { - elapsed := time.Since(*lastEventTime) - if elapsed < MinEventInterval { - sleepTime := MinEventInterval - elapsed - fmt.Printf("[stream] THROTTLE sleep=%v (elapsed=%v)\n", sleepTime, elapsed) - time.Sleep(sleepTime) - } - } - event.Publish(e) - if lastEventTime != nil { - *lastEventTime = time.Now() - } -} - // processMessageChunk handles a single message chunk from the stream. func (p *Processor) processMessageChunk( ctx context.Context, @@ -197,7 +186,6 @@ func (p *Processor) processMessageChunk( currentToolParts map[string]*types.ToolPart, accumulatedContent *string, accumulatedToolInputs map[string]string, - lastEventTime *time.Time, ) string { var finishReason string @@ -219,15 +207,13 @@ func (p *Processor) processMessageChunk( *accumulatedContent = msg.Content // Publish delta event for FIRST chunk (SDK compatible) - // This ensures the TUI receives and displays the first text chunk - // Note: Uses throttledPublish to prevent TUI batching - throttledPublish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: *currentTextPart, Delta: msg.Content, // First chunk IS the delta }, - }, lastEventTime) + }) callback(state.message, state.parts) } else { @@ -246,14 +232,13 @@ func (p *Processor) processMessageChunk( } // Publish delta event (SDK compatible: uses MessagePartUpdated) - // Note: Uses throttledPublish to prevent TUI batching - throttledPublish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: *currentTextPart, Delta: delta, }, - }, lastEventTime) + }) callback(state.message, state.parts) } @@ -292,14 +277,13 @@ func (p *Processor) processMessageChunk( // Fallback: use ID-based tracking if Index not available toolIndex = -1 // Will use ID map } else { - fmt.Printf("[stream] Skipping tool call with no Index and no ID\n") continue } // Determine lookup key - use index string or ID var lookupKey string if toolIndex >= 0 { - lookupKey = fmt.Sprintf("idx:%d", toolIndex) + lookupKey = "idx:" + string(rune('0'+toolIndex)) } else { lookupKey = tc.ID } @@ -323,11 +307,9 @@ func (p *Processor) processMessageChunk( Time: &types.ToolTime{Start: now}, }, } - fmt.Printf("[stream] Created new ToolPart: tool=%s, callID=%s, index=%d\n", toolPart.Tool, toolPart.CallID, toolIndex) currentToolParts[lookupKey] = toolPart accumulatedToolInputs[lookupKey] = "" state.parts = append(state.parts, toolPart) - fmt.Printf("[stream] Added toolPart to state.parts, total parts=%d\n", len(state.parts)) callback(state.message, state.parts) } @@ -336,18 +318,15 @@ func (p *Processor) processMessageChunk( // Append arguments (eino sends deltas, not accumulated) accumulatedToolInputs[lookupKey] += tc.Function.Arguments toolPart.State.Raw = accumulatedToolInputs[lookupKey] - fmt.Printf("[stream] Tool %s accumulated args: %s\n", toolPart.Tool, truncate(accumulatedToolInputs[lookupKey], 100)) // Try to parse accumulated JSON var input map[string]any if err := json.Unmarshal([]byte(accumulatedToolInputs[lookupKey]), &input); err == nil { toolPart.State.Input = input - fmt.Printf("[stream] Tool %s parsed input: %v\n", toolPart.Tool, input) } // Publish tool part update (SDK compatible: uses MessagePartUpdated) - // Note: Must use async Publish so SSE select loop can process events - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: toolPart, @@ -358,21 +337,10 @@ func (p *Processor) processMessageChunk( } } - // Check for response metadata (token usage) - if msg.ResponseMeta != nil { - if state.message.Tokens == nil { - state.message.Tokens = &types.TokenUsage{} - } - - if msg.ResponseMeta.Usage != nil { - state.message.Tokens.Input = msg.ResponseMeta.Usage.PromptTokens - state.message.Tokens.Output = msg.ResponseMeta.Usage.CompletionTokens - } - - // Check finish reason - if msg.ResponseMeta.FinishReason != "" { - finishReason = msg.ResponseMeta.FinishReason - } + // Check for finish reason in response metadata + // Note: Token usage is collected in processStream and applied after the stream completes + if msg.ResponseMeta != nil && msg.ResponseMeta.FinishReason != "" { + finishReason = msg.ResponseMeta.FinishReason } return finishReason diff --git a/go-opencode/internal/session/title.go b/go-opencode/internal/session/title.go new file mode 100644 index 00000000000..cc32f197889 --- /dev/null +++ b/go-opencode/internal/session/title.go @@ -0,0 +1,123 @@ +package session + +import ( + "context" + "io" + "strings" + + "github.com/cloudwego/eino/schema" + + "github.com/opencode-ai/opencode/internal/event" + "github.com/opencode-ai/opencode/internal/provider" + "github.com/opencode-ai/opencode/pkg/types" +) + +const titleSystemPrompt = `You are a title generator. You output ONLY a thread title. Nothing else. + +Generate a brief title that would help the user find this conversation later. + +Rules: +- A single line, ≤50 characters +- No explanations +- Use -ing verbs for actions (Debugging, Implementing, Analyzing) +- Keep exact: technical terms, numbers, filenames +- Remove: the, this, my, a, an +- Always output something meaningful + +Examples: +"debug 500 errors in production" → Debugging production 500 errors +"refactor user service" → Refactoring user service +"implement rate limiting" → Implementing rate limiting` + +const defaultTitlePrefix = "New Session" + +// isDefaultTitle checks if a title is the default "New Session" title. +func isDefaultTitle(title string) bool { + return title == defaultTitlePrefix || strings.HasPrefix(title, defaultTitlePrefix) +} + +// ensureTitle generates a title for the session if it's still using the default title. +// Should only be called on the first user message. +func (p *Processor) ensureTitle( + ctx context.Context, + session *types.Session, + userContent string, +) { + // Skip if session has a parent (child session) + if session.ParentID != nil && *session.ParentID != "" { + return + } + + // Skip if title is not the default + if !isDefaultTitle(session.Title) { + return + } + + // Get the default model for title generation + model, err := p.providerRegistry.DefaultModel() + if err != nil { + return + } + + prov, err := p.providerRegistry.Get(model.ProviderID) + if err != nil { + return + } + + // Create title generation request + stream, err := prov.CreateCompletion(ctx, &provider.CompletionRequest{ + Model: model.ID, + Messages: []*schema.Message{ + {Role: schema.System, Content: titleSystemPrompt}, + {Role: schema.User, Content: "Generate a title for this conversation:\n\n" + userContent}, + }, + MaxTokens: 50, // Short title + }) + if err != nil { + return + } + defer stream.Close() + + // Collect response + var title strings.Builder + for { + msg, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return + } + title.WriteString(msg.Content) + } + + // Clean up title + titleText := strings.TrimSpace(title.String()) + // Get first non-empty line + for _, line := range strings.Split(titleText, "\n") { + line = strings.TrimSpace(line) + if line != "" { + titleText = line + break + } + } + + // Truncate if too long + if len(titleText) > 100 { + titleText = titleText[:97] + "..." + } + + if titleText == "" { + return + } + + // Update session title + session.Title = titleText + p.storage.Put(ctx, []string{"session", session.ProjectID, session.ID}, session) + + // Publish session.updated event + event.PublishSync(event.Event{ + Type: event.SessionUpdated, + Data: event.SessionUpdatedData{Info: session}, + }) +} diff --git a/go-opencode/internal/session/todo.go b/go-opencode/internal/session/todo.go new file mode 100644 index 00000000000..c0b1df9be43 --- /dev/null +++ b/go-opencode/internal/session/todo.go @@ -0,0 +1,38 @@ +// Package session provides session management functionality. +package session + +import ( + "context" + + "github.com/opencode-ai/opencode/internal/event" + "github.com/opencode-ai/opencode/internal/storage" + "github.com/opencode-ai/opencode/pkg/types" +) + +// GetTodos retrieves todos for a session. +func GetTodos(ctx context.Context, store *storage.Storage, sessionID string) ([]types.TodoInfo, error) { + var todos []types.TodoInfo + err := store.Get(ctx, []string{"todo", sessionID}, &todos) + if err == storage.ErrNotFound { + return []types.TodoInfo{}, nil + } + if err != nil { + return nil, err + } + return todos, nil +} + +// UpdateTodos updates todos for a session and publishes an event. +func UpdateTodos(ctx context.Context, store *storage.Storage, sessionID string, todos []types.TodoInfo) error { + if err := store.Put(ctx, []string{"todo", sessionID}, todos); err != nil { + return err + } + event.Publish(event.Event{ + Type: event.TodoUpdated, + Data: map[string]any{ + "sessionID": sessionID, + "todos": todos, + }, + }) + return nil +} diff --git a/go-opencode/internal/session/tools.go b/go-opencode/internal/session/tools.go index 4e37009fe8f..43e7d039737 100644 --- a/go-opencode/internal/session/tools.go +++ b/go-opencode/internal/session/tools.go @@ -23,23 +23,6 @@ func (p *Processor) executeToolCalls( agent *Agent, callback ProcessCallback, ) error { - fmt.Printf("[tools] executeToolCalls called, state.parts=%d\n", len(state.parts)) - - // Log each part's type and status for debugging - for i, part := range state.parts { - switch tp := part.(type) { - case *types.ToolPart: - fmt.Printf("[tools] Part %d: ToolPart tool=%s status=%s callID=%s\n", - i, tp.Tool, tp.State.Status, tp.CallID) - case *types.TextPart: - fmt.Printf("[tools] Part %d: TextPart len=%d\n", i, len(tp.Text)) - case *types.ReasoningPart: - fmt.Printf("[tools] Part %d: ReasoningPart len=%d\n", i, len(tp.Text)) - default: - fmt.Printf("[tools] Part %d: %T\n", i, part) - } - } - // Find all running tool parts var pendingTools []*types.ToolPart for _, part := range state.parts { @@ -50,18 +33,13 @@ func (p *Processor) executeToolCalls( } } - fmt.Printf("[tools] Found %d pending tools with status='running'\n", len(pendingTools)) - // Execute each tool for _, toolPart := range pendingTools { - fmt.Printf("[tools] About to execute tool: %s (callID=%s)\n", toolPart.Tool, toolPart.CallID) err := p.executeSingleTool(ctx, state, agent, toolPart, callback) if err != nil { - fmt.Printf("[tools] Tool execution error: %v\n", err) // Error is captured in tool part, don't stop processing continue } - fmt.Printf("[tools] Tool execution completed: %s\n", toolPart.Tool) } return nil @@ -75,20 +53,12 @@ func (p *Processor) executeSingleTool( toolPart *types.ToolPart, callback ProcessCallback, ) error { - fmt.Printf("[tools] executeSingleTool: tool=%s, callID=%s\n", toolPart.Tool, toolPart.CallID) - // Get the tool from registry t, ok := p.toolRegistry.Get(toolPart.Tool) if !ok { - fmt.Printf("[tools] Tool NOT FOUND in registry: %s\n", toolPart.Tool) - // List available tools for debugging - if p.toolRegistry != nil { - fmt.Printf("[tools] Available tools: %v\n", p.toolRegistry.List()) - } return p.failTool(ctx, state, toolPart, callback, fmt.Sprintf("Tool not found: %s", toolPart.Tool)) } - fmt.Printf("[tools] Tool found in registry: %s\n", t.ID()) // Check permissions if err := p.checkToolPermission(ctx, state, agent, toolPart); err != nil { @@ -142,7 +112,7 @@ func (p *Processor) executeSingleTool( } // Publish event (SDK compatible: uses MessagePartUpdated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: toolPart, @@ -191,15 +161,13 @@ func (p *Processor) executeSingleTool( } // Record diff for edit-like tools when metadata contains before/after - if err := p.recordDiff(state, toolPart); err != nil { - fmt.Printf("[tools] failed to record diff: %v\n", err) - } + p.recordDiff(state, toolPart) // Save updated part p.savePart(ctx, state.message.ID, toolPart) // Publish event (SDK compatible: uses MessagePartUpdated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: toolPart, @@ -226,7 +194,7 @@ func (p *Processor) failTool( p.savePart(ctx, state.message.ID, toolPart) // Publish event (SDK compatible: uses MessagePartUpdated) - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.MessagePartUpdated, Data: event.MessagePartUpdatedData{ Part: toolPart, @@ -371,7 +339,7 @@ func (p *Processor) recordDiff(state *sessionState, toolPart *types.ToolPart) er } // Publish updated session diff - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.SessionDiff, Data: event.SessionDiffData{SessionID: session.ID, Diff: session.Summary.Diffs}, }) diff --git a/go-opencode/internal/tool/bash_test.go b/go-opencode/internal/tool/bash_test.go index b09513272ca..4a39e976085 100644 --- a/go-opencode/internal/tool/bash_test.go +++ b/go-opencode/internal/tool/bash_test.go @@ -232,8 +232,8 @@ func TestBashTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Bash" { - t.Errorf("Expected name 'Bash', got %q", info.Name) + if info.Name != "bash" { + t.Errorf("Expected name 'bash', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/edit.go b/go-opencode/internal/tool/edit.go index 273f0c43cfa..3fc23a0f490 100644 --- a/go-opencode/internal/tool/edit.go +++ b/go-opencode/internal/tool/edit.go @@ -116,7 +116,7 @@ func (t *EditTool) Execute(ctx context.Context, input json.RawMessage, toolCtx * // Publish event (SDK compatible: just file path) if toolCtx != nil && toolCtx.SessionID != "" { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.FileEdited, Data: event.FileEditedData{ File: params.FilePath, @@ -150,7 +150,7 @@ func (t *EditTool) fuzzyReplace(text string, params EditInput, toolCtx *Context) // Publish event (SDK compatible: just file path) if toolCtx != nil && toolCtx.SessionID != "" { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.FileEdited, Data: event.FileEditedData{ File: params.FilePath, @@ -179,7 +179,7 @@ func (t *EditTool) fuzzyReplace(text string, params EditInput, toolCtx *Context) // Publish event (SDK compatible: just file path) if toolCtx != nil && toolCtx.SessionID != "" { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.FileEdited, Data: event.FileEditedData{ File: params.FilePath, diff --git a/go-opencode/internal/tool/edit_test.go b/go-opencode/internal/tool/edit_test.go index e3875d24c49..b9562bde8dc 100644 --- a/go-opencode/internal/tool/edit_test.go +++ b/go-opencode/internal/tool/edit_test.go @@ -317,8 +317,8 @@ func TestEditTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Edit" { - t.Errorf("Expected name 'Edit', got %q", info.Name) + if info.Name != "edit" { + t.Errorf("Expected name 'edit', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/glob_test.go b/go-opencode/internal/tool/glob_test.go index 602059f23cb..c9a65a6a2bf 100644 --- a/go-opencode/internal/tool/glob_test.go +++ b/go-opencode/internal/tool/glob_test.go @@ -222,8 +222,8 @@ func TestGlobTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Glob" { - t.Errorf("Expected name 'Glob', got %q", info.Name) + if info.Name != "glob" { + t.Errorf("Expected name 'glob', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/grep_test.go b/go-opencode/internal/tool/grep_test.go index cf07d59372a..efbdb1a0733 100644 --- a/go-opencode/internal/tool/grep_test.go +++ b/go-opencode/internal/tool/grep_test.go @@ -292,8 +292,8 @@ func TestGrepTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Grep" { - t.Errorf("Expected name 'Grep', got %q", info.Name) + if info.Name != "grep" { + t.Errorf("Expected name 'grep', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/list_test.go b/go-opencode/internal/tool/list_test.go index 638bd915382..5d45899acca 100644 --- a/go-opencode/internal/tool/list_test.go +++ b/go-opencode/internal/tool/list_test.go @@ -248,7 +248,7 @@ func TestListTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "List" { - t.Errorf("Expected name 'List', got %q", info.Name) + if info.Name != "list" { + t.Errorf("Expected name 'list', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/read_test.go b/go-opencode/internal/tool/read_test.go index 949f8517555..a5e5adc6f6a 100644 --- a/go-opencode/internal/tool/read_test.go +++ b/go-opencode/internal/tool/read_test.go @@ -285,7 +285,7 @@ func TestReadTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Read" { - t.Errorf("Expected name 'Read', got %q", info.Name) + if info.Name != "read" { + t.Errorf("Expected name 'read', got %q", info.Name) } } diff --git a/go-opencode/internal/tool/registry.go b/go-opencode/internal/tool/registry.go index 81832d88525..cff5e4e5503 100644 --- a/go-opencode/internal/tool/registry.go +++ b/go-opencode/internal/tool/registry.go @@ -6,6 +6,7 @@ import ( einotool "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" + "github.com/opencode-ai/opencode/internal/storage" ) // Registry manages tool registration and lookup. @@ -13,16 +14,23 @@ type Registry struct { mu sync.RWMutex tools map[string]Tool workDir string + storage *storage.Storage } // NewRegistry creates a new tool registry. -func NewRegistry(workDir string) *Registry { +func NewRegistry(workDir string, store *storage.Storage) *Registry { return &Registry{ tools: make(map[string]Tool), workDir: workDir, + storage: store, } } +// Storage returns the storage instance. +func (r *Registry) Storage() *storage.Storage { + return r.storage +} + // Register adds a tool to the registry. func (r *Registry) Register(tool Tool) { r.mu.Lock() @@ -93,9 +101,9 @@ func (r *Registry) ToolInfos() ([]*schema.ToolInfo, error) { } // DefaultRegistry creates a registry with all built-in tools. -func DefaultRegistry(workDir string) *Registry { +func DefaultRegistry(workDir string, store *storage.Storage) *Registry { fmt.Printf("[registry] Creating DefaultRegistry with workDir=%s\n", workDir) - r := NewRegistry(workDir) + r := NewRegistry(workDir, store) // Register core tools r.Register(NewReadTool(workDir)) @@ -106,6 +114,12 @@ func DefaultRegistry(workDir string) *Registry { r.Register(NewGrepTool(workDir)) r.Register(NewListTool(workDir)) + // Register todo tools + r.Register(NewTodoWriteTool(workDir, store)) + r.Register(NewTodoReadTool(workDir, store)) + + // Note: TaskTool requires agent registry, register separately if needed + fmt.Printf("[registry] DefaultRegistry created with %d tools: %v\n", len(r.tools), r.IDs()) return r } diff --git a/go-opencode/internal/tool/registry_test.go b/go-opencode/internal/tool/registry_test.go index e2426fce6e1..9de65d6a2fd 100644 --- a/go-opencode/internal/tool/registry_test.go +++ b/go-opencode/internal/tool/registry_test.go @@ -34,7 +34,7 @@ func newMockTool(id, description string) *mockTool { } func TestRegistry_RegisterAndGet(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) tool := newMockTool("test_tool", "A test tool") registry.Register(tool) @@ -49,7 +49,7 @@ func TestRegistry_RegisterAndGet(t *testing.T) { } func TestRegistry_GetNotFound(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) _, ok := registry.Get("nonexistent") if ok { @@ -58,7 +58,7 @@ func TestRegistry_GetNotFound(t *testing.T) { } func TestRegistry_List(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) registry.Register(newMockTool("tool1", "Tool 1")) registry.Register(newMockTool("tool2", "Tool 2")) @@ -71,7 +71,7 @@ func TestRegistry_List(t *testing.T) { } func TestRegistry_IDs(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) registry.Register(newMockTool("alpha", "Alpha")) registry.Register(newMockTool("beta", "Beta")) @@ -91,7 +91,7 @@ func TestRegistry_IDs(t *testing.T) { } func TestRegistry_EinoTools(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) registry.Register(newMockTool("tool1", "Tool 1")) registry.Register(newMockTool("tool2", "Tool 2")) @@ -103,7 +103,7 @@ func TestRegistry_EinoTools(t *testing.T) { } func TestRegistry_ToolInfos(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) tool := &mockTool{ id: "read_file", @@ -136,10 +136,10 @@ func TestRegistry_ToolInfos(t *testing.T) { } func TestDefaultRegistry(t *testing.T) { - registry := DefaultRegistry("/tmp") + registry := DefaultRegistry("/tmp", nil) - // Check that core tools are registered - expectedTools := []string{"Read", "Write", "Edit", "Bash", "Glob", "Grep", "List"} + // Check that core tools are registered (lowercase to match TS-opencode) + expectedTools := []string{"read", "write", "edit", "bash", "glob", "grep", "list"} for _, name := range expectedTools { _, ok := registry.Get(name) @@ -156,7 +156,7 @@ func TestDefaultRegistry(t *testing.T) { } func TestRegistry_ConcurrentAccess(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) done := make(chan bool) for i := 0; i < 10; i++ { @@ -181,7 +181,7 @@ func TestRegistry_ConcurrentAccess(t *testing.T) { } func TestRegistry_ReplaceExisting(t *testing.T) { - registry := NewRegistry("/tmp") + registry := NewRegistry("/tmp", nil) // Register initial tool tool1 := newMockTool("mytool", "Original description") diff --git a/go-opencode/internal/tool/task_test.go b/go-opencode/internal/tool/task_test.go index 742b573de6a..6c32505cab5 100644 --- a/go-opencode/internal/tool/task_test.go +++ b/go-opencode/internal/tool/task_test.go @@ -13,7 +13,7 @@ import ( func TestNewTaskTool(t *testing.T) { tool := NewTaskTool("/tmp", nil) assert.NotNil(t, tool) - assert.Equal(t, "Task", tool.ID()) + assert.Equal(t, "task", tool.ID()) assert.NotEmpty(t, tool.Description()) } diff --git a/go-opencode/internal/tool/todoread.go b/go-opencode/internal/tool/todoread.go new file mode 100644 index 00000000000..af3569e11ff --- /dev/null +++ b/go-opencode/internal/tool/todoread.go @@ -0,0 +1,70 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + + einotool "github.com/cloudwego/eino/components/tool" + "github.com/opencode-ai/opencode/internal/storage" + "github.com/opencode-ai/opencode/pkg/types" +) + +const todoreadDescription = `Use this tool to read your todo list` + +// TodoReadTool reads the current todo list for a session. +type TodoReadTool struct { + workDir string + storage *storage.Storage +} + +// NewTodoReadTool creates a new todoread tool. +func NewTodoReadTool(workDir string, store *storage.Storage) *TodoReadTool { + return &TodoReadTool{ + workDir: workDir, + storage: store, + } +} + +func (t *TodoReadTool) ID() string { return "todoread" } +func (t *TodoReadTool) Description() string { return todoreadDescription } + +func (t *TodoReadTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": {}, + "required": [] + }`) +} + +func (t *TodoReadTool) Execute(ctx context.Context, input json.RawMessage, toolCtx *Context) (*Result, error) { + // Get todos directly from storage (avoiding session import) + var todos []types.TodoInfo + err := t.storage.Get(ctx, []string{"todo", toolCtx.SessionID}, &todos) + if err == storage.ErrNotFound { + todos = []types.TodoInfo{} + } else if err != nil { + return nil, fmt.Errorf("failed to get todos: %w", err) + } + + // Count non-completed todos + nonCompleted := 0 + for _, todo := range todos { + if todo.Status != "completed" { + nonCompleted++ + } + } + + output, _ := json.MarshalIndent(todos, "", " ") + return &Result{ + Title: fmt.Sprintf("%d todos", nonCompleted), + Output: string(output), + Metadata: map[string]any{ + "todos": todos, + }, + }, nil +} + +func (t *TodoReadTool) EinoTool() einotool.InvokableTool { + return &einoToolWrapper{tool: t} +} diff --git a/go-opencode/internal/tool/todowrite.go b/go-opencode/internal/tool/todowrite.go new file mode 100644 index 00000000000..d9dfb5c07f1 --- /dev/null +++ b/go-opencode/internal/tool/todowrite.go @@ -0,0 +1,156 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + + einotool "github.com/cloudwego/eino/components/tool" + "github.com/opencode-ai/opencode/internal/event" + "github.com/opencode-ai/opencode/internal/storage" + "github.com/opencode-ai/opencode/pkg/types" +) + +const todowriteDescription = `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Exactly ONE task must be in_progress at any time (not less, not more) + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.` + +// TodoWriteTool manages structured task lists for coding sessions. +type TodoWriteTool struct { + workDir string + storage *storage.Storage +} + +// TodoWriteInput represents the input for the todowrite tool. +type TodoWriteInput struct { + Todos []types.TodoInfo `json:"todos"` +} + +// NewTodoWriteTool creates a new todowrite tool. +func NewTodoWriteTool(workDir string, store *storage.Storage) *TodoWriteTool { + return &TodoWriteTool{ + workDir: workDir, + storage: store, + } +} + +func (t *TodoWriteTool) ID() string { return "todowrite" } +func (t *TodoWriteTool) Description() string { return todowriteDescription } + +func (t *TodoWriteTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The updated todo list", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the todo item" + }, + "content": { + "type": "string", + "description": "Brief description of the task" + }, + "status": { + "type": "string", + "description": "Current status of the task: pending, in_progress, completed" + }, + "priority": { + "type": "string", + "description": "Priority level of the task: high, medium, low" + } + }, + "required": ["id", "content", "status", "priority"] + } + } + }, + "required": ["todos"] + }`) +} + +func (t *TodoWriteTool) Execute(ctx context.Context, input json.RawMessage, toolCtx *Context) (*Result, error) { + var params TodoWriteInput + if err := json.Unmarshal(input, ¶ms); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + // Store todos directly (avoiding session import) + if err := t.storage.Put(ctx, []string{"todo", toolCtx.SessionID}, params.Todos); err != nil { + return nil, fmt.Errorf("failed to update todos: %w", err) + } + + // Publish event + event.Publish(event.Event{ + Type: event.TodoUpdated, + Data: map[string]any{ + "sessionID": toolCtx.SessionID, + "todos": params.Todos, + }, + }) + + // Count non-completed todos + nonCompleted := 0 + for _, todo := range params.Todos { + if todo.Status != "completed" { + nonCompleted++ + } + } + + output, _ := json.MarshalIndent(params.Todos, "", " ") + return &Result{ + Title: fmt.Sprintf("%d todos", nonCompleted), + Output: string(output), + Metadata: map[string]any{ + "todos": params.Todos, + }, + }, nil +} + +func (t *TodoWriteTool) EinoTool() einotool.InvokableTool { + return &einoToolWrapper{tool: t} +} diff --git a/go-opencode/internal/tool/tools_test.go b/go-opencode/internal/tool/tools_test.go index 8a1a475ee92..56c5efc4210 100644 --- a/go-opencode/internal/tool/tools_test.go +++ b/go-opencode/internal/tool/tools_test.go @@ -34,8 +34,8 @@ func TestEinoToolWrapper_Info(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Read" { - t.Errorf("Expected name 'Read', got %q", info.Name) + if info.Name != "read" { + t.Errorf("Expected name 'read', got %q", info.Name) } if info.Desc == "" { t.Error("Description should not be empty") diff --git a/go-opencode/internal/tool/write.go b/go-opencode/internal/tool/write.go index 4e03e3b95f7..36ff05fb063 100644 --- a/go-opencode/internal/tool/write.go +++ b/go-opencode/internal/tool/write.go @@ -75,7 +75,7 @@ func (t *WriteTool) Execute(ctx context.Context, input json.RawMessage, toolCtx // Publish file edited event (SDK compatible: just file path) if toolCtx != nil && toolCtx.SessionID != "" { - event.Publish(event.Event{ + event.PublishSync(event.Event{ Type: event.FileEdited, Data: event.FileEditedData{ File: params.FilePath, diff --git a/go-opencode/internal/tool/write_test.go b/go-opencode/internal/tool/write_test.go index 2f7960b71b9..278a3d6cf79 100644 --- a/go-opencode/internal/tool/write_test.go +++ b/go-opencode/internal/tool/write_test.go @@ -200,7 +200,7 @@ func TestWriteTool_EinoTool(t *testing.T) { t.Fatalf("Info failed: %v", err) } - if info.Name != "Write" { - t.Errorf("Expected name 'Write', got %q", info.Name) + if info.Name != "write" { + t.Errorf("Expected name 'write', got %q", info.Name) } } diff --git a/go-opencode/pkg/types/session.go b/go-opencode/pkg/types/session.go index 72140591200..3396065ad50 100644 --- a/go-opencode/pkg/types/session.go +++ b/go-opencode/pkg/types/session.go @@ -96,3 +96,12 @@ type TUIControlRequest struct { Path string `json:"path"` Body any `json:"body"` } + +// TodoInfo represents a task item in a session's todo list. +// Used by the TodoWrite and TodoRead tools to track progress. +type TodoInfo struct { + ID string `json:"id"` + Content string `json:"content"` + Status string `json:"status"` // pending, in_progress, completed, cancelled + Priority string `json:"priority"` // high, medium, low +}