fix(cli): prevent duplicate rendering of auto-approved file tools (#782)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<string, unknown>, 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<ToolCallLine>(b, id, () => ({
|
||||
let line = ensure<ToolCallLine>(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" &&
|
||||
|
||||
@@ -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) || {
|
||||
|
||||
Reference in New Issue
Block a user