From c751af9894ddf172aee82c2d27c7c324ec866bda Mon Sep 17 00:00:00 2001 From: Joohwi Lee Date: Thu, 4 Dec 2025 20:39:10 -0800 Subject: [PATCH 1/3] feat: add console log capture to file for TUI mode Add ability to capture console.log/error/warn/debug calls to log files in TUI mode. This is useful for debugging since stdout/stderr are not visible when the TUI is running. Changes: - Add captureConsole option to Log.init() and Log.captureConsole() fn - Add restoreConsole() to restore original console methods - Enable console capture in TUI thread, worker, attach, and spawn cmds - Console output is logged with service="console" in the log file --- packages/opencode/src/cli/cmd/tui/attach.ts | 4 + packages/opencode/src/cli/cmd/tui/spawn.ts | 4 + packages/opencode/src/cli/cmd/tui/thread.ts | 4 + packages/opencode/src/cli/cmd/tui/worker.ts | 2 + packages/opencode/src/util/log.ts | 88 +++++++++++++++++++++ 5 files changed, 102 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 7da6507ea01..baa0ffe831b 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -1,5 +1,6 @@ import { cmd } from "../cmd" import { tui } from "./app" +import { Log } from "@/util/log" export const AttachCommand = cmd({ command: "attach ", @@ -16,6 +17,9 @@ export const AttachCommand = cmd({ description: "directory to run in", }), handler: async (args) => { + // Capture console.log/error/warn/debug to log file in TUI mode + Log.captureConsole() + if (args.dir) process.chdir(args.dir) await tui({ url: args.url, diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index fa679529890..fb597a31f2f 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -3,6 +3,7 @@ import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" +import { Log } from "@/util/log" export const TuiSpawnCommand = cmd({ command: "spawn [project]", @@ -23,6 +24,9 @@ export const TuiSpawnCommand = cmd({ default: "127.0.0.1", }), handler: async (args) => { + // Capture console.log/error/warn/debug to log file in TUI mode + Log.captureConsole() + upgrade() const server = Server.listen({ port: args.port, diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 79638c5e876..bbf88c780a7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -55,6 +55,10 @@ export const TuiThreadCommand = cmd({ default: "127.0.0.1", }), handler: async (args) => { + // Capture console.log/error/warn/debug to log file in TUI mode + // This must be called after Log.init() which is done in the CLI middleware + Log.captureConsole() + // Resolve relative paths against PWD to preserve behavior when using --cwd flag const baseCwd = process.env.PWD ?? process.cwd() const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 7754b4a3953..74878e6e02c 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -13,6 +13,8 @@ await Log.init({ if (Installation.isLocal()) return "DEBUG" return "INFO" })(), + // Capture console.log/error/warn/debug to log file in TUI mode + captureConsole: true, }) process.on("unhandledRejection", (e) => { diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 209f7303272..4aa257f183f 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -4,6 +4,16 @@ import { Global } from "../global" import z from "zod" export namespace Log { + // Store original console methods for restoration + const originalConsole = { + log: console.log.bind(console), + error: console.error.bind(console), + warn: console.warn.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + } + + let consoleIntercepted = false export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) export type Level = z.infer @@ -44,6 +54,8 @@ export namespace Log { print: boolean dev?: boolean level?: Level + /** When true, intercept console.log/error/warn/debug and write them to the log file */ + captureConsole?: boolean } let logpath = "" @@ -68,6 +80,82 @@ export namespace Log { writer.flush() return num } + + // Set up console interception if requested + if (options.captureConsole) { + captureConsole() + } + } + + /** + * Format console arguments to a string for logging + */ + function formatConsoleArgs(args: any[]): string { + return args + .map((arg) => { + if (arg === undefined) return "undefined" + if (arg === null) return "null" + if (arg instanceof Error) return formatError(arg) + if (typeof arg === "object") { + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } + } + return String(arg) + }) + .join(" ") + } + + /** + * Intercept console.log/error/warn/debug and write them to the log file. + * This is useful for capturing all console output in TUI mode where + * stdout/stderr are not visible. + */ + export function captureConsole() { + if (consoleIntercepted) return + consoleIntercepted = true + + const consoleLogger = create({ service: "console" }) + + console.log = (...args: any[]) => { + const message = formatConsoleArgs(args) + consoleLogger.info(message) + } + + console.error = (...args: any[]) => { + const message = formatConsoleArgs(args) + consoleLogger.error(message) + } + + console.warn = (...args: any[]) => { + const message = formatConsoleArgs(args) + consoleLogger.warn(message) + } + + console.debug = (...args: any[]) => { + const message = formatConsoleArgs(args) + consoleLogger.debug(message) + } + + console.info = (...args: any[]) => { + const message = formatConsoleArgs(args) + consoleLogger.info(message) + } + } + + /** + * Restore original console methods + */ + export function restoreConsole() { + if (!consoleIntercepted) return + consoleIntercepted = false + console.log = originalConsole.log + console.error = originalConsole.error + console.warn = originalConsole.warn + console.debug = originalConsole.debug + console.info = originalConsole.info } async function cleanup(dir: string) { From f14a40a7d3622cffbaa145efb52ca8eb5684609a Mon Sep 17 00:00:00 2001 From: Joohwi Lee Date: Thu, 4 Dec 2025 21:09:30 -0800 Subject: [PATCH 2/3] feat(sdk): add JSON parse error logging for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add console.error logging for JSON parse failures that were previously silently caught: - SSE JSON parse errors (with url, eventName, rawData) - SSE connection errors (with url, attempt, error) - API error response JSON parse failures (with url, status, rawBody) Logs use [SDK] prefix and are captured to log file in TUI mode via captureConsole. Also adds patch-generated.ts script that automatically re-applies these patches after SDK regeneration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/sdk/js/script/build.ts | 4 + packages/sdk/js/script/patch-generated.ts | 122 ++++++++++++++++++ packages/sdk/js/src/gen/client/client.gen.ts | 13 +- .../js/src/gen/core/serverSentEvents.gen.ts | 10 +- 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/js/script/patch-generated.ts diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index c1fe0f42334..5e8ae95905b 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -36,6 +36,10 @@ await createClient({ }, ], }) + +// Apply error logging patches to generated files +await $`bun ${path.join(dir, "script/patch-generated.ts")}` + await $`bun prettier --write src/gen` await $`rm -rf dist` await $`bun tsc` diff --git a/packages/sdk/js/script/patch-generated.ts b/packages/sdk/js/script/patch-generated.ts new file mode 100644 index 00000000000..b845ab72c20 --- /dev/null +++ b/packages/sdk/js/script/patch-generated.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env bun +/** + * Patches auto-generated SDK files to add error logging. + * + * This script should be run after @hey-api/openapi-ts regenerates the client. + * It adds console.error logging for JSON parse failures that would otherwise + * be silently caught. + * + * Run: bun ./script/patch-generated.ts + */ + +import fs from "fs" +import path from "path" + +const dir = new URL("..", import.meta.url).pathname + +const patches: Array<{ + file: string + description: string + find: string + replace: string +}> = [ + { + file: "src/gen/core/serverSentEvents.gen.ts", + description: "Add SSE JSON parse error logging", + find: ` try { + data = JSON.parse(rawData) + parsedJson = true + } catch { + data = rawData + }`, + replace: ` try { + data = JSON.parse(rawData) + parsedJson = true + } catch (parseError) { + console.error("[SDK] SSE JSON parse error:", { + url, + eventName, + rawData: rawData.substring(0, 500), + error: parseError, + }) + data = rawData + }`, + }, + { + file: "src/gen/core/serverSentEvents.gen.ts", + description: "Add SSE connection error logging", + find: ` } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error)`, + replace: ` } catch (error) { + // connection failed or aborted; retry after delay + console.error("[SDK] SSE connection error:", { url, attempt, error }) + onSseError?.(error)`, + }, + { + file: "src/gen/client/client.gen.ts", + description: "Add API error response JSON parse error logging", + find: ` try { + jsonError = JSON.parse(textError) + } catch { + // noop + }`, + replace: ` try { + jsonError = JSON.parse(textError) + } catch (parseError) { + // Log JSON parse failure for error responses + if (textError.trim().startsWith("{") || textError.trim().startsWith("[")) { + console.error("[SDK] API error response JSON parse failure:", { + url: request.url, + status: response.status, + rawBody: textError.substring(0, 500), + error: parseError, + }) + } + }`, + }, +] + +// Add header comment to patched files +const headerComment = "// NOTE: Manual modifications for error logging - will be overwritten on regeneration\n" + +let success = true + +for (const patch of patches) { + const filePath = path.join(dir, patch.file) + + if (!fs.existsSync(filePath)) { + console.error(`[SKIP] File not found: ${patch.file}`) + continue + } + + let content = fs.readFileSync(filePath, "utf-8") + + // Add header comment if not already present + if (!content.includes("Manual modifications for error logging")) { + content = content.replace( + "// This file is auto-generated by @hey-api/openapi-ts\n", + "// This file is auto-generated by @hey-api/openapi-ts\n" + headerComment + ) + } + + // Apply patch + if (content.includes(patch.find)) { + content = content.replace(patch.find, patch.replace) + fs.writeFileSync(filePath, content) + console.log(`[OK] ${patch.description} (${patch.file})`) + } else if (content.includes(patch.replace)) { + console.log(`[SKIP] Already patched: ${patch.description}`) + } else { + console.error(`[FAIL] Could not find pattern for: ${patch.description}`) + console.error(` File: ${patch.file}`) + success = false + } +} + +if (success) { + console.log("\nAll patches applied successfully!") +} else { + console.error("\nSome patches failed. The generated code may have changed.") + process.exit(1) +} diff --git a/packages/sdk/js/src/gen/client/client.gen.ts b/packages/sdk/js/src/gen/client/client.gen.ts index 34a8d0beceb..4b5912a8055 100644 --- a/packages/sdk/js/src/gen/client/client.gen.ts +++ b/packages/sdk/js/src/gen/client/client.gen.ts @@ -1,4 +1,5 @@ // This file is auto-generated by @hey-api/openapi-ts +// NOTE: Manual modifications for error logging - will be overwritten on regeneration import { createSseClient } from "../core/serverSentEvents.gen.js" import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" @@ -150,8 +151,16 @@ export const createClient = (config: Config = {}): Client => { try { jsonError = JSON.parse(textError) - } catch { - // noop + } catch (parseError) { + // Log JSON parse failure for error responses + if (textError.trim().startsWith("{") || textError.trim().startsWith("[")) { + console.error("[SDK] API error response JSON parse failure:", { + url: request.url, + status: response.status, + rawBody: textError.substring(0, 500), + error: parseError, + }) + } } const error = jsonError ?? textError diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts index 8f7fac549d2..91d204b2916 100644 --- a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -1,4 +1,5 @@ // This file is auto-generated by @hey-api/openapi-ts +// NOTE: Manual modifications for error logging - will be overwritten on regeneration import type { Config } from "./types.gen.js" @@ -156,7 +157,13 @@ export const createSseClient = ({ try { data = JSON.parse(rawData) parsedJson = true - } catch { + } catch (parseError) { + console.error("[SDK] SSE JSON parse error:", { + url, + eventName, + rawData: rawData.substring(0, 500), + error: parseError, + }) data = rawData } } @@ -191,6 +198,7 @@ export const createSseClient = ({ break // exit loop on normal completion } catch (error) { // connection failed or aborted; retry after delay + console.error("[SDK] SSE connection error:", { url, attempt, error }) onSseError?.(error) if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { From 585df555a6288557d05b115ea1fcfbc6633bd125 Mon Sep 17 00:00:00 2001 From: Joohwi Lee Date: Thu, 4 Dec 2025 22:15:31 -0800 Subject: [PATCH 3/3] fix: SDK compatibility for user message display and text streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes multiple issues preventing proper TUI integration: ## User Message Display (handlers_message.go) - Change event type from `message.created` to `message.updated` (TUI only listens for `message.updated`, not `message.created`) - Add `message.part.updated` events for user message parts - Add required `sessionID` and `messageID` fields to TextPart and FilePart (TUI stores parts by messageID - without it, parts can't be associated) ## Text Streaming (stream.go) - Fix accumulated vs delta detection: use `strings.HasPrefix` instead of length comparison to correctly identify streaming mode - Publish `message.part.updated` event for first text chunk (was only calling callback, not publishing event) - Fix tool call tracking to use Index-based lookup (eino streaming model) - Accumulate tool arguments as deltas, not replace ## Message Processing (loop.go) - Reload messages after tool execution to include tool results - Skip empty messages (no parts) in completion requests - Add debug logging for message building ## Tool Execution (tools.go, registry.go) - Add debug logging for tool execution flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../internal/server/handlers_message.go | 48 ++++++++---- go-opencode/internal/session/loop.go | 29 +++++++ go-opencode/internal/session/stream.go | 75 +++++++++++++++---- go-opencode/internal/session/tools.go | 30 ++++++++ go-opencode/internal/tool/registry.go | 4 + 5 files changed, 159 insertions(+), 27 deletions(-) diff --git a/go-opencode/internal/server/handlers_message.go b/go-opencode/internal/server/handlers_message.go index a30309ec71d..99298827b91 100644 --- a/go-opencode/internal/server/handlers_message.go +++ b/go-opencode/internal/server/handlers_message.go @@ -104,11 +104,13 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { return } - // Create user message parts + // Create user message parts (SDK compatible: include sessionID and messageID) textPart := &types.TextPart{ - ID: generateID(), - Type: "text", - Text: content, + ID: generateID(), + SessionID: sessionID, + MessageID: userMsg.ID, + Type: "text", + Text: content, } userParts := []types.Part{textPart} @@ -118,24 +120,44 @@ func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { return } - // Add file parts if provided - for _, file := range req.Files { - file.ID = generateID() - file.Type = "file" - userParts = append(userParts, &file) + // Add file parts if provided (SDK compatible: include sessionID and messageID) + for i := range req.Files { + req.Files[i].ID = generateID() + req.Files[i].SessionID = sessionID + req.Files[i].MessageID = userMsg.ID + req.Files[i].Type = "file" + userParts = append(userParts, &req.Files[i]) // Save file part to storage - if err := s.sessionService.SavePart(r.Context(), userMsg.ID, &file); err != nil { + if err := s.sessionService.SavePart(r.Context(), userMsg.ID, &req.Files[i]); err != nil { writeError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error()) return } } - // Publish user message via SSE (not in HTTP response) + // Publish user message via SSE (SDK compatible: uses message.updated) + event.Publish(event.Event{ + Type: event.MessageUpdated, + Data: event.MessageUpdatedData{Info: userMsg}, + }) + + // Publish user message parts (SDK compatible: uses message.part.updated) event.Publish(event.Event{ - Type: "message.created", - Data: event.MessageCreatedData{Info: userMsg}, + Type: event.MessagePartUpdated, + Data: event.MessagePartUpdatedData{ + Part: textPart, + }, }) + // Publish file parts if any + for i := range req.Files { + event.Publish(event.Event{ + Type: event.MessagePartUpdated, + Data: event.MessagePartUpdatedData{ + Part: &req.Files[i], + }, + }) + } + // Process message and generate response // This is where the LLM provider is called // Updates are published via SSE, not streamed in HTTP response diff --git a/go-opencode/internal/session/loop.go b/go-opencode/internal/session/loop.go index bfbda76fe66..a4483240691 100644 --- a/go-opencode/internal/session/loop.go +++ b/go-opencode/internal/session/loop.go @@ -184,6 +184,13 @@ func (p *Processor) runLoop( messages, _ = p.loadMessages(ctx, sessionID) } + // Reload messages to include the current assistant message and tool results + messages, err = p.loadMessages(ctx, sessionID) + 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) if err != nil { @@ -310,10 +317,13 @@ func (p *Processor) runLoop( case "tool_use", "tool_calls": // Execute tools and continue loop + fmt.Printf("[loop] Got tool_use/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 @@ -430,16 +440,29 @@ 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 } @@ -450,6 +473,9 @@ func (p *Processor) buildCompletionRequest( 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 @@ -459,6 +485,9 @@ func (p *Processor) buildCompletionRequest( toolContent = "Error: " + toolPart.State.Error } + 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, diff --git a/go-opencode/internal/session/stream.go b/go-opencode/internal/session/stream.go index 48fc3dc0c85..c6f4846316f 100644 --- a/go-opencode/internal/session/stream.go +++ b/go-opencode/internal/session/stream.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "strings" "time" "github.com/cloudwego/eino/schema" @@ -86,7 +87,10 @@ 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 { @@ -94,6 +98,7 @@ 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) } @@ -150,17 +155,28 @@ func (p *Processor) processMessageChunk( } state.parts = append(state.parts, *currentTextPart) *accumulatedContent = msg.Content + + // Publish delta event for FIRST chunk (SDK compatible) + // This ensures the TUI receives and displays the first text chunk + event.Publish(event.Event{ + Type: event.MessagePartUpdated, + Data: event.MessagePartUpdatedData{ + Part: *currentTextPart, + Delta: msg.Content, // First chunk IS the delta + }, + }) + callback(state.message, state.parts) } else { - // Check if this is accumulated content (longer than previous) or delta content (shorter) + // Check if this is accumulated content (starts with previous) or delta content (new chunk only) var delta string - if len(msg.Content) > len(*accumulatedContent) { - // Accumulated mode: extract delta from difference + if strings.HasPrefix(msg.Content, *accumulatedContent) { + // Accumulated mode: new content STARTS WITH all previous content delta = msg.Content[len(*accumulatedContent):] (*currentTextPart).Text = msg.Content *accumulatedContent = msg.Content } else { - // Delta mode: append delta directly + // Delta mode: new content is just the new part delta = msg.Content *accumulatedContent += msg.Content (*currentTextPart).Text = *accumulatedContent @@ -200,10 +216,34 @@ func (p *Processor) processMessageChunk( } // Handle tool calls + // The eino streaming model uses Index to track tool calls: + // - Start event: Index=N, ID="toolu_xxx", Name="Read" + // - Delta events: Index=N, ID="", Name="", Arguments='{"partial...' for _, tc := range msg.ToolCalls { - toolPart, exists := currentToolParts[tc.ID] - if !exists { - // New tool call + // Use Index to track tool calls (eino streaming model) + var toolIndex int + if tc.Index != nil { + toolIndex = *tc.Index + } else if tc.ID != "" { + // 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) + } else { + lookupKey = tc.ID + } + + toolPart, exists := currentToolParts[lookupKey] + + // New tool call (has ID and Name) + if !exists && tc.ID != "" && tc.Function.Name != "" { now := time.Now().UnixMilli() toolPart = &types.ToolPart{ ID: generatePartID(), @@ -219,19 +259,26 @@ func (p *Processor) processMessageChunk( Time: &types.ToolTime{Start: now}, }, } - currentToolParts[tc.ID] = toolPart - accumulatedToolInputs[tc.ID] = "" + 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) } - // Accumulate arguments - if tc.Function.Arguments != "" { - accumulatedToolInputs[tc.ID] = tc.Function.Arguments - toolPart.State.Raw = tc.Function.Arguments + // Accumulate arguments (delta chunks have arguments but no ID/Name) + if tc.Function.Arguments != "" && toolPart != nil { + // 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(tc.Function.Arguments), &input); err == nil { + 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) diff --git a/go-opencode/internal/session/tools.go b/go-opencode/internal/session/tools.go index 46875a383ae..7a9b2dff7a2 100644 --- a/go-opencode/internal/session/tools.go +++ b/go-opencode/internal/session/tools.go @@ -19,6 +19,23 @@ 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 { @@ -29,13 +46,18 @@ 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 @@ -49,12 +71,20 @@ 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 { diff --git a/go-opencode/internal/tool/registry.go b/go-opencode/internal/tool/registry.go index e2043076ce4..81832d88525 100644 --- a/go-opencode/internal/tool/registry.go +++ b/go-opencode/internal/tool/registry.go @@ -1,6 +1,7 @@ package tool import ( + "fmt" "sync" einotool "github.com/cloudwego/eino/components/tool" @@ -26,6 +27,7 @@ func NewRegistry(workDir string) *Registry { func (r *Registry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() + fmt.Printf("[registry] Registering tool: %s\n", tool.ID()) r.tools[tool.ID()] = tool } @@ -92,6 +94,7 @@ func (r *Registry) ToolInfos() ([]*schema.ToolInfo, error) { // DefaultRegistry creates a registry with all built-in tools. func DefaultRegistry(workDir string) *Registry { + fmt.Printf("[registry] Creating DefaultRegistry with workDir=%s\n", workDir) r := NewRegistry(workDir) // Register core tools @@ -103,5 +106,6 @@ func DefaultRegistry(workDir string) *Registry { r.Register(NewGrepTool(workDir)) r.Register(NewListTool(workDir)) + fmt.Printf("[registry] DefaultRegistry created with %d tools: %v\n", len(r.tools), r.IDs()) return r }