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);
}
}