diff --git a/src/cli/App.tsx b/src/cli/App.tsx index af4ed30..fc7f070 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1575,6 +1575,19 @@ export default function App({ const deferredCommits = deferredToolCallCommitsRef.current; const now = Date.now(); let blockedByDeferred = false; + // If we eagerly committed a tall preview for file tools, don't also + // commit the successful tool_call line (preview already represents it). + const shouldSkipCommittedToolCall = (ln: Line): boolean => { + if (ln.kind !== "tool_call") return false; + if (!ln.toolCallId || !ln.name) return false; + if (ln.phase !== "finished" || ln.resultOk === false) return false; + if (!eagerCommittedPreviewsRef.current.has(ln.toolCallId)) return false; + return ( + isFileEditTool(ln.name) || + isFileWriteTool(ln.name) || + isPatchTool(ln.name) + ); + }; if (!deferToolCalls && deferredCommits.size > 0) { deferredCommits.clear(); setDeferredCommitAt(null); @@ -1646,6 +1659,11 @@ export default function App({ continue; } if ("phase" in ln && ln.phase === "finished") { + if (shouldSkipCommittedToolCall(ln)) { + deferredCommits.delete(id); + emittedIdsRef.current.add(id); + continue; + } if ( deferToolCalls && ln.kind === "tool_call" && @@ -8361,17 +8379,30 @@ ${SYSTEM_REMINDER_CLOSE} ...(additionalDecision ? [additionalDecision] : []), ]; - executingToolCallIdsRef.current = allDecisions - .filter((decision) => decision.type === "approve") - .map((decision) => decision.approval.toolCallId); + const approvedDecisions = allDecisions.filter( + ( + decision, + ): decision is { + type: "approve"; + approval: ApprovalRequest; + precomputedResult?: ToolExecutionResult; + } => decision.type === "approve", + ); + const runningDecisions = approvedDecisions.filter( + (decision) => !decision.precomputedResult, + ); + + executingToolCallIdsRef.current = runningDecisions.map( + (decision) => decision.approval.toolCallId, + ); // Set phase to "running" for all approved tools - setToolCallsRunning( - buffersRef.current, - allDecisions - .filter((d) => d.type === "approve") - .map((d) => d.approval.toolCallId), - ); + if (runningDecisions.length > 0) { + setToolCallsRunning( + buffersRef.current, + runningDecisions.map((d) => d.approval.toolCallId), + ); + } refreshDerived(); // Execute approved tools and format results using shared function @@ -9770,12 +9801,6 @@ ${SYSTEM_REMINDER_CLOSE} setThinkingMessage(getRandomThinkingVerb()); refreshDerived(); - // Mark as eagerly committed to prevent duplicate rendering - // (sendAllResults will call setToolCallsRunning which resets phase to "running") - if (approval.toolCallId) { - eagerCommittedPreviewsRef.current.add(approval.toolCallId); - } - const decision = { type: "approve" as const, approval, @@ -10095,53 +10120,55 @@ Plan file path: ${planFilePath}`; items={staticItems} style={{ flexDirection: "column" }} > - {(item: StaticItem, index: number) => ( - 0 ? 1 : 0}> - {item.kind === "welcome" ? ( - - ) : item.kind === "user" ? ( - - ) : item.kind === "reasoning" ? ( - - ) : item.kind === "assistant" ? ( - - ) : item.kind === "tool_call" ? ( - - ) : item.kind === "subagent_group" ? ( - - ) : item.kind === "error" ? ( - - ) : item.kind === "status" ? ( - - ) : item.kind === "event" ? ( - - ) : item.kind === "separator" ? ( - - {"─".repeat(columns)} - - ) : item.kind === "command" ? ( - - ) : item.kind === "bash_command" ? ( - - ) : item.kind === "trajectory_summary" ? ( - - ) : item.kind === "approval_preview" ? ( - - ) : null} - - )} + {(item: StaticItem, index: number) => { + return ( + 0 ? 1 : 0}> + {item.kind === "welcome" ? ( + + ) : item.kind === "user" ? ( + + ) : item.kind === "reasoning" ? ( + + ) : item.kind === "assistant" ? ( + + ) : item.kind === "tool_call" ? ( + + ) : item.kind === "subagent_group" ? ( + + ) : item.kind === "error" ? ( + + ) : item.kind === "status" ? ( + + ) : item.kind === "event" ? ( + + ) : item.kind === "separator" ? ( + + {"─".repeat(columns)} + + ) : item.kind === "command" ? ( + + ) : item.kind === "bash_command" ? ( + + ) : item.kind === "trajectory_summary" ? ( + + ) : item.kind === "approval_preview" ? ( + + ) : null} + + ); + }} @@ -10162,6 +10189,21 @@ Plan file path: ${planFilePath}`; {liveItems.length > 0 && ( {liveItems.map((ln) => { + const isFileTool = + ln.kind === "tool_call" && + ln.name && + (isFileEditTool(ln.name) || + isFileWriteTool(ln.name) || + isPatchTool(ln.name)); + const isApprovalTracked = + ln.kind === "tool_call" && + ln.toolCallId && + (ln.toolCallId === currentApproval?.toolCallId || + pendingIds.has(ln.toolCallId) || + queuedIds.has(ln.toolCallId)); + if (isFileTool && !isApprovalTracked) { + return null; + } // Skip Task tools that don't have a pending approval // They render as empty Boxes (ToolCallMessage returns null for non-finished Task tools) // which causes N blank lines when N Task tools are called in parallel @@ -10178,18 +10220,6 @@ Plan file path: ${planFilePath}`; return null; } - // Skip tool calls that were eagerly committed to staticItems - // (e.g., ExitPlanMode preview) - but only AFTER approval is complete - // We still need to render the approval options while awaiting approval - if ( - ln.kind === "tool_call" && - ln.toolCallId && - eagerCommittedPreviewsRef.current.has(ln.toolCallId) && - ln.toolCallId !== currentApproval?.toolCallId - ) { - return null; - } - // Check if this tool call matches the current approval awaiting user input const matchesCurrentApproval = ln.kind === "tool_call" && diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 0a9154c..7652d58 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -314,8 +314,7 @@ export function markCurrentLineAsFinished(b: Buffers) { // console.log(`[MARK_CURRENT_FINISHED] No lastOtid, returning`); return; } - // Try both the plain otid and the -tool suffix (in case of collision workaround) - const prev = b.byId.get(b.lastOtid) || b.byId.get(`${b.lastOtid}-tool`); + const prev = b.byId.get(b.lastOtid); // console.log(`[MARK_CURRENT_FINISHED] Found line: kind=${prev?.kind}, phase=${(prev as any)?.phase}`); if (prev && (prev.kind === "assistant" || prev.kind === "reasoning")) { // console.log(`[MARK_CURRENT_FINISHED] Marking ${b.lastOtid} as finished`); @@ -386,6 +385,7 @@ function getStringProp(obj: Record, key: string) { const v = obj[key]; return typeof v === "string" ? v : undefined; } + function extractTextPart(v: unknown): string { if (typeof v === "string") return v; if (Array.isArray(v)) { @@ -562,25 +562,8 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { case "tool_call_message": case "approval_request_message": { - /* POST-FIX VERSION (what this should look like after backend fix): - const id = chunk.otid; - // Handle otid transition (mark previous line as finished) - handleOtidTransition(b, id); - - if (!id) break; - - const toolCall = chunk.tool_call || (Array.isArray(chunk.tool_calls) && chunk.tool_calls.length > 0 ? chunk.tool_calls[0] : null); - const toolCallId = toolCall?.tool_call_id; - const name = toolCall?.name; - const argsText = toolCall?.arguments; - - // Record correlation: toolCallId → line id (otid) - if (toolCallId) b.toolCallIdToLineId.set(toolCallId, id); - */ - - let id = chunk.otid; - // console.log(`[TOOL_CALL] Received ${chunk.message_type} with otid=${id}, toolCallId=${chunk.tool_call?.tool_call_id}, name=${chunk.tool_call?.name}`); + handleOtidTransition(b, chunk.otid ?? undefined); // Use deprecated tool_call or new tool_calls array const toolCall = @@ -588,74 +571,38 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { (Array.isArray(chunk.tool_calls) && chunk.tool_calls.length > 0 ? chunk.tool_calls[0] : null); + if (!toolCall || !toolCall.tool_call_id) break; - const toolCallId = toolCall?.tool_call_id; - const name = toolCall?.name; - const argsText = toolCall?.arguments; + const toolCallId = toolCall.tool_call_id; + const name = toolCall.name; + const argsText = toolCall.arguments; - // ========== START BACKEND BUG WORKAROUND (Remove after OTID fix) ========== - // Bug: Backend sends same otid for reasoning and tool_call, and multiple otids for same tool_call - - // Check if we already have a line for this toolCallId (prevents duplicates) - if (toolCallId && b.toolCallIdToLineId.has(toolCallId)) { - // Update the existing line instead of creating a new one - const existingId = b.toolCallIdToLineId.get(toolCallId); - if (existingId) { - id = existingId; - } - - // Handle otid transition for tracking purposes - handleOtidTransition(b, chunk.otid ?? undefined); - } else { - // Check if this otid is already used by another line - if (id && b.byId.has(id)) { - const existing = b.byId.get(id); - if (existing && existing.kind === "reasoning") { - // Mark the reasoning as finished before we create the tool_call - markAsFinished(b, id); - // Use a different ID for the tool_call to avoid overwriting the reasoning - id = `${id}-tool`; - } else if (existing && existing.kind === "tool_call") { - // Parallel tool calls: same otid, different tool_call_id - // Create unique ID for this parallel tool using its tool_call_id - if (toolCallId) { - id = `${id}-${toolCallId.slice(-8)}`; - } else { - // Fallback: append timestamp - id = `${id}-${Date.now().toString(36)}`; - } - } - } - // ========== END BACKEND BUG WORKAROUND ========== - - // This part stays after fix: - // Handle otid transition (mark previous line as finished) - // This must happen BEFORE the break, so reasoning gets finished even when tool has no otid - handleOtidTransition(b, id ?? undefined); - - if (!id) { - // console.log(`[TOOL_CALL] No otid, breaking`); - break; - } - - // Record correlation: toolCallId → line id (otid) for future updates - if (toolCallId) b.toolCallIdToLineId.set(toolCallId, id); + // Use tool_call_id as the stable line id (server guarantees uniqueness). + const id = b.toolCallIdToLineId.get(toolCallId) ?? toolCallId; + if (!b.toolCallIdToLineId.has(toolCallId)) { + b.toolCallIdToLineId.set(toolCallId, id); } - // Early exit if no valid id - if (!id) break; - // Tool calls should be "ready" (blinking) while pending execution - // Only approval requests explicitly set to "ready", but regular tool calls should also blink const desiredPhase = "ready"; - const line = ensure(b, id, () => ({ + let line = ensure(b, id, () => ({ kind: "tool_call", id, - toolCallId: toolCallId ?? undefined, + toolCallId, name: name ?? undefined, phase: desiredPhase, })); + // If additional metadata arrives later (e.g., name), update the line. + if ((name && !line.name) || line.toolCallId !== toolCallId) { + line = { + ...line, + toolCallId, + name: line.name ?? name ?? undefined, + }; + b.byId.set(id, line); + } + // If this is an approval request and the line already exists, bump phase to ready if ( chunk.message_type === "approval_request_message" && diff --git a/src/cli/helpers/streamProcessor.ts b/src/cli/helpers/streamProcessor.ts index 4fb2bda..9147081 100644 --- a/src/cli/helpers/streamProcessor.ts +++ b/src/cli/helpers/streamProcessor.ts @@ -41,9 +41,6 @@ export class StreamProcessor { public lastSeqId: number | null = null; public stopReason: StopReasonType | null = null; - // Approval ID fallback (for backends that don't include tool_call_id in every chunk) - private lastApprovalId: string | null = null; - processChunk(chunk: LettaStreamingResponse): ChunkProcessingResult { let errorInfo: ErrorInfo | undefined; let updatedApproval: ApprovalRequest | undefined; @@ -110,11 +107,6 @@ export class StreamProcessor { // Continue processing this chunk (for UI display) } - // Need to store the approval request ID to send an approval in a new run - if (chunk.message_type === "approval_request_message") { - this.lastApprovalId = chunk.id; - } - // Accumulate approval request state across streaming chunks // Support parallel tool calls by tracking each tool_call_id separately // NOTE: Only track approval_request_message, NOT tool_call_message @@ -134,21 +126,9 @@ export class StreamProcessor { : []; for (const toolCall of toolCalls) { - // Many backends stream tool_call chunks where only the first frame - // carries the tool_call_id; subsequent argument deltas omit it. - // Fall back to the last seen id within this turn so we can - // properly accumulate args. - let id: string | null = toolCall?.tool_call_id ?? this.lastApprovalId; - if (!id) { - // As an additional guard, if exactly one approval is being - // tracked already, use that id for continued argument deltas. - if (this.pendingApprovals.size === 1) { - id = Array.from(this.pendingApprovals.keys())[0] ?? null; - } - } - if (!id) continue; // cannot safely attribute this chunk - - this.lastApprovalId = id; + const toolCallId = toolCall?.tool_call_id; + if (!toolCallId) continue; // contract: approval chunks include tool_call_id + const id = toolCallId; // Get or create entry for this tool_call_id const existing = this.pendingApprovals.get(id) || { diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 57048fb..6d9f11f 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,5 +1,9 @@ // src/utils/debug.ts // Simple debug logging utility - only logs when LETTA_DEBUG env var is set +// Optionally logs to a file when LETTA_DEBUG_FILE is set + +import { appendFileSync } from "node:fs"; +import { format } from "node:util"; /** * Check if debug mode is enabled via LETTA_DEBUG env var @@ -10,6 +14,30 @@ export function isDebugEnabled(): boolean { return debug === "1" || debug === "true"; } +function getDebugFile(): string | null { + const path = process.env.LETTA_DEBUG_FILE; + return path && path.trim().length > 0 ? path : null; +} + +function writeDebugLine( + prefix: string, + message: string, + args: unknown[], +): void { + const debugFile = getDebugFile(); + const line = `${format(`[${prefix}] ${message}`, ...args)}\n`; + if (debugFile) { + try { + appendFileSync(debugFile, line, { encoding: "utf8" }); + return; + } catch { + // Fall back to console if file write fails + } + } + // Default to console output + console.log(line.trimEnd()); +} + /** * Log a debug message (only if LETTA_DEBUG is enabled) * @param prefix - A prefix/tag for the log message (e.g., "check-approval") @@ -22,7 +50,7 @@ export function debugLog( ...args: unknown[] ): void { if (isDebugEnabled()) { - console.log(`[${prefix}] ${message}`, ...args); + writeDebugLine(prefix, message, args); } } @@ -38,6 +66,6 @@ export function debugWarn( ...args: unknown[] ): void { if (isDebugEnabled()) { - console.warn(`[${prefix}] ${message}`, ...args); + writeDebugLine(prefix, `WARN: ${message}`, args); } }