From d9683cfe296b3382e7aac44d011bf6a01ced5946 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Jul 2026 11:35:30 +0200 Subject: [PATCH] fix: structured approvals + ToolCallGroup reactivity - Replace text-based regex parsing in InlineApproval with structured pendingApprovals extracted from request_execution tool results. The tool result text is deterministic (not LLM-generated), making UUID extraction reliable regardless of how the LLM rephrases the response. - Fix ToolCallGroup reactivity: wasActive = active captured initial value. Now uses (active) so re-runs on prop changes. - Extract approvals in both live streaming (done event) and history loading for consistent behavior on resumed sessions. --- web/src/lib/components/InlineApproval.svelte | 80 ++++++++++---------- web/src/lib/components/ToolCallGroup.svelte | 2 +- web/src/lib/stores/chat.ts | 56 ++++++++++++-- web/src/pages/Chat.svelte | 2 +- 4 files changed, 88 insertions(+), 52 deletions(-) diff --git a/web/src/lib/components/InlineApproval.svelte b/web/src/lib/components/InlineApproval.svelte index dc8fa8f..d750d6c 100644 --- a/web/src/lib/components/InlineApproval.svelte +++ b/web/src/lib/components/InlineApproval.svelte @@ -1,4 +1,5 @@ -{#if match && !done} -
- - This action requires approval - {#if pending} - - {:else} - - - {/if} -
-{:else if done} -
- {#if done === 'approved'} +{#each approvals.filter(a => !done || a.executionId !== doneId) as approval (approval.executionId)} + {#if !done || approval.executionId !== doneId} +
+ + + {approval.action} on {approval.target} requires approval + + {#if pending && doneId === approval.executionId} + + {:else} + + + {/if} +
+ {:else if done === 'approved'} +
Approved. The action is running. - {:else} +
+ {:else} +
Denied. - {/if} -
-{/if} +
+ {/if} +{/each} diff --git a/web/src/lib/components/ToolCallGroup.svelte b/web/src/lib/components/ToolCallGroup.svelte index 8658be5..c4eea6f 100644 --- a/web/src/lib/components/ToolCallGroup.svelte +++ b/web/src/lib/components/ToolCallGroup.svelte @@ -10,7 +10,7 @@ let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props() let open = $state(false) - let wasActive = active + let wasActive = $state(active) $effect(() => { if (active && !wasActive) { diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index 2a05f56..bc03c7a 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -2,11 +2,38 @@ import { writable, get } from 'svelte/store' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api' +export interface PendingApproval { + executionId: string + action: string + target: string +} + export interface ChatMessage { id: string role: 'user' | 'assistant' text: string tools: ToolCallResult[] + pendingApprovals: PendingApproval[] +} + +const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i + +function extractApprovals(tools: ToolCallResult[]): PendingApproval[] { + const out: PendingApproval[] = [] + for (const t of tools) { + if (t.name !== 'request_execution' || t.type !== 'tool_result') continue + const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '') + if (!text.includes('requires approval')) continue + const m = text.match(APPROVAL_RE) + if (m) { + out.push({ + executionId: m[1], + action: t.args?.action ?? 'unknown', + target: t.args?.target ?? 'unknown' + }) + } + } + return out } export interface ToolCallResult { @@ -57,12 +84,16 @@ export async function loadSessionMessages(sessionId: string) { currentSession.set(sessionId) const msgs = await fetchMessages(sessionId) sessionMessages.set(msgs) - const chatMsgs: ChatMessage[] = msgs.map((m) => ({ - id: m.id, - role: m.role as 'user' | 'assistant', - text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), - tools: mergeToolCalls(m.content?.tool_calls) - })) + const chatMsgs: ChatMessage[] = msgs.map((m) => { + const tools = mergeToolCalls(m.content?.tool_calls) + return { + id: m.id, + role: m.role as 'user' | 'assistant', + text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), + tools, + pendingApprovals: extractApprovals(tools) + } + }) messages.set(chatMsgs) } @@ -74,7 +105,8 @@ export function sendMessage(text: string) { id: mid(), role: 'user', text, - tools: [] + tools: [], + pendingApprovals: [] } messages.update((ms) => [...ms, userMsg]) @@ -82,7 +114,8 @@ export function sendMessage(text: string) { id: mid(), role: 'assistant', text: '', - tools: [] + tools: [], + pendingApprovals: [] } messages.update((ms) => [...ms, assistantMsg]) @@ -147,6 +180,13 @@ export function sendMessage(text: string) { return [...ms] }) } else if (ev.type === 'done') { + messages.update((ms) => { + const last = ms[ms.length - 1] + if (last && last.role === 'assistant') { + last.pendingApprovals = extractApprovals(last.tools) + } + return [...ms] + }) currentSession.set(ev.data?.session_id ?? ev.session_id) } else if (ev.type === 'error') { error.set(ev.data) diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte index c169352..ff859a3 100644 --- a/web/src/pages/Chat.svelte +++ b/web/src/pages/Chat.svelte @@ -114,7 +114,7 @@ {:else}
- + {#if msg.text}