diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index 10ba4100b..0d12a7c0e 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -241,6 +241,37 @@ function SessionChatView({ session }: { session: SessionSummary }) { [body, sending, session.agentId, session.sessionId, refresh] ); + // A runtime tool-approval decision → reply "allow"/"deny" to the peer. Rethrows + // on failure so SessionTranscript rolls the card back to buttons for a retry. + const decide = useCallback( + async (decision: 'allow' | 'deny'): Promise => { + setSendError(null); + debug( + '[orchestration:agent-chat] approval decision: send session=%s decision=%s', + session.sessionId, + decision + ); + try { + await orchestrationClient.sendMasterMessage({ + body: decision, + recipient: session.agentId, + sessionId: session.sessionId, + }); + if (mountedRef.current) void refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debug( + '[orchestration:agent-chat] approval decision: failed session=%s %s', + session.sessionId, + message + ); + if (mountedRef.current) setSendError(message); + throw error; + } + }, + [session.agentId, session.sessionId, refresh] + ); + const runtime = session.harnessType || session.source || null; const directory = session.workspace?.trim() || null; const runningOn = session.agentId?.trim() || null; @@ -323,7 +354,10 @@ function SessionChatView({ session }: { session: SessionSummary }) { {t('tinyplaceOrchestration.noMessages')}

) : ( - + decide(decision === 'deny' ? 'deny' : 'allow')} + /> )} diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx index dd4695bb1..35c44e7c9 100644 --- a/app/src/components/orchestration/ConnectionsPanel.tsx +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -127,6 +127,36 @@ function SessionView({ [body, sending, contactAddr, session.sessionId, refresh] ); + // Runtime tool-approval decision → reply "allow"/"deny" to the runtime peer (the + // same send path as a typed reply). Returns the promise (rethrows on failure) so + // SessionTranscript rolls the card back to buttons for a retry. + const decide = useCallback( + (decision: 'allow' | 'deny'): Promise => { + setSendError(null); + debug( + '[orchestration:connections] approval decision: send session=%s decision=%s', + session.sessionId, + decision + ); + return orchestrationClient + .sendMasterMessage({ body: decision, recipient: contactAddr, sessionId: session.sessionId }) + .then(() => { + void refresh(); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + debug( + '[orchestration:connections] approval decision: failed session=%s %s', + session.sessionId, + message + ); + setSendError(message); + throw error; + }); + }, + [contactAddr, session.sessionId, refresh] + ); + return (
- + {allowAlways ? ( + + ) : null}
) : null} @@ -189,8 +271,30 @@ function ApprovalRow({ export default function SessionTranscript({ messages, onDecide, + alwaysAllow = false, }: SessionTranscriptProps): ReactElement { - const rows = mergeToolActivity(messages); + // Persistent outcomes derived from the message stream (survive reload); only the + // decision echoes actually paired to an approval are suppressed from the render. + const { decided: derivedDecided, suppressed } = deriveDecisions(messages); + const rows = mergeToolActivity(messages.filter(m => !suppressed.has(m.id))); + // Local clicks give immediate feedback before the echo message arrives; they + // layer on top of the derived (persisted) outcomes. Rolled back if the send + // fails, so the buttons return for a retry. + const [clicked, setClicked] = useState>({}); + const rollback = (id: string): void => + setClicked(prev => { + const next = { ...prev }; + delete next[id]; + return next; + }); + const handleDecide = onDecide + ? (message: ChatMessage, decision: ApprovalDecision): void => { + setClicked(prev => ({ ...prev, [message.id]: decision })); + Promise.resolve(onDecide(message, decision)).catch(() => rollback(message.id)); + } + : undefined; + const decidedFor = (id: string): ApprovalDecision | undefined => + clicked[id] ?? derivedDecided[id]; return (
{rows.map((row, i) => { @@ -204,7 +308,15 @@ export default function SessionTranscript({ case 'error': return ; case 'approval_request': - return ; + return ( + + ); default: // agent_message + legacy v1 rows → bubble by sender. Owner/user- // authored rows (incl. a reply mirrored back with role "owner") sit diff --git a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx index 540f16167..6af647abe 100644 --- a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx +++ b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx @@ -84,6 +84,7 @@ describe('AgentChatPanel', () => { beforeEach(() => { vi.clearAllMocks(); contactSessions.current = []; + transcript.current = { state: { status: 'ok' }, messages: [], refresh: vi.fn() }; chatsApi.current = { ...chatsApi.current, selectedId: 'master', @@ -145,6 +146,35 @@ describe('AgentChatPanel', () => { ); }); + it('routes a runtime tool-approval decision back as an allow reply', async () => { + contactSessions.current = [pinged]; + transcript.current = { + state: { status: 'ok' }, + messages: [ + { + id: 'ap', + from: 'agent', + body: 'gh pr status', + timestamp: '2026-07-08T00:00:00Z', + encrypted: false, + eventKind: 'approval_request', + toolName: 'shell', + }, + ], + refresh: vi.fn(), + }; + render(); + fireEvent.click(screen.getByTestId('orch-agent-view-session-s-auth')); + fireEvent.click(screen.getByText('chat.approval.approve')); + await waitFor(() => + expect(sendMasterMessage).toHaveBeenCalledWith({ + body: 'allow', + recipient: '@peer', + sessionId: 's-auth', + }) + ); + }); + it('surfaces a session reply failure', async () => { contactSessions.current = [pinged]; sendMasterMessage.mockRejectedValueOnce(new Error('boom')); diff --git a/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx index 440f3f37b..aa8c202eb 100644 --- a/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx +++ b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx @@ -151,6 +151,38 @@ describe('ConnectionsPanel', () => { expect(screen.getByTestId('orch-connections-panel')).toBeInTheDocument(); }); + it('routes a runtime tool-approval decision back as an allow reply', async () => { + const contact = { agentId: ADDR, status: 'accepted', direction: 'outgoing' }; + sessionsHook.byContact.set(ADDR, [session({})]); + pairing.current = { ...pairing.current, state: okState([contact]) as never }; + transcriptHook.current = { + state: { status: 'ok' }, + messages: [ + { + id: 'ap', + from: 'agent', + body: 'gh pr status', + timestamp: '2026-07-08T00:00:00Z', + encrypted: false, + eventKind: 'approval_request', + toolName: 'shell', + }, + ], + refresh: vi.fn(), + }; + render(); + fireEvent.click(screen.getByTestId(`orch-connection-${ADDR}`).querySelector('button')!); + fireEvent.click(await screen.findByTestId('orch-session-s1')); + fireEvent.click(screen.getByText('chat.approval.approve')); + await waitFor(() => + expect(sendMasterMessage).toHaveBeenCalledWith({ + body: 'allow', + recipient: ADDR, + sessionId: 's1', + }) + ); + }); + it('surfaces a reply send failure instead of swallowing it', async () => { const contact = { agentId: ADDR, status: 'accepted', direction: 'outgoing' }; sessionsHook.byContact.set(ADDR, [session({})]); diff --git a/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx b/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx index 5f94458ca..d557d6f72 100644 --- a/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx +++ b/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ChatMessage } from '../../../lib/orchestration/useOrchestrationChats'; @@ -73,13 +73,76 @@ describe('SessionTranscript', () => { expect(screen.queryByText('chat.approval.approve')).not.toBeInTheDocument(); }); - it('wires approval buttons to onDecide', () => { + it('wires approval buttons to onDecide and resolves the card in place', () => { const onDecide = vi.fn(); const approval = msg({ id: 'ap', eventKind: 'approval_request', body: 'run it' }); render(); - fireEvent.click(screen.getByText('chat.approval.approve')); + fireEvent.click(screen.getByRole('button', { name: 'chat.approval.approve' })); expect(onDecide).toHaveBeenCalledWith(approval, 'approve'); - fireEvent.click(screen.getByText('chat.approval.deny')); + // After deciding, the card resolves in place: buttons are gone, outcome shown. + expect(screen.queryByRole('button', { name: 'chat.approval.deny' })).not.toBeInTheDocument(); + expect(screen.getByTestId('approval-resolved')).toBeInTheDocument(); + }); + + it('resolves to a denied outcome when denied', () => { + const onDecide = vi.fn(); + const approval = msg({ id: 'ap', eventKind: 'approval_request', body: 'rm -rf' }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'chat.approval.deny' })); expect(onDecide).toHaveBeenCalledWith(approval, 'deny'); + expect(screen.queryByRole('button', { name: 'chat.approval.approve' })).not.toBeInTheDocument(); + expect(screen.getByTestId('approval-resolved')).toHaveTextContent('chat.approval.deny'); + }); + + it('resolves from a persisted decision echo (survives reload) and suppresses that echo', () => { + // No onDecide (as after remount), but a paired decision echo is present — the + // card renders resolved, and the redundant echo bubble is hidden. + render( + + ); + expect(screen.getByTestId('approval-resolved')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'chat.approval.approve' })).not.toBeInTheDocument(); + expect(screen.queryByText('allow')).not.toBeInTheDocument(); + }); + + it('preserves an unpaired one-word owner reply (real chat, no approval)', () => { + render( + + ); + // no preceding approval → "allow" is a normal reply and must stay visible + expect(screen.getByText('allow')).toBeInTheDocument(); + expect(screen.getByText('the answer')).toBeInTheDocument(); + }); + + it('hides "Always allow" by default and shows it only when enabled', () => { + const approval = msg({ id: 'ap', eventKind: 'approval_request', body: 'run it' }); + const { rerender } = render(); + expect( + screen.queryByRole('button', { name: 'chat.approval.alwaysAllow' }) + ).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole('button', { name: 'chat.approval.alwaysAllow' })).toBeInTheDocument(); + }); + + it('rolls the card back to buttons if the decision send fails', async () => { + const onDecide = vi.fn().mockRejectedValue(new Error('relay down')); + const approval = msg({ id: 'ap', eventKind: 'approval_request', body: 'run it' }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'chat.approval.approve' })); + // optimistic resolve, then rollback on rejection → the buttons return for retry + await waitFor(() => + expect(screen.getByRole('button', { name: 'chat.approval.approve' })).toBeInTheDocument() + ); + expect(screen.queryByTestId('approval-resolved')).not.toBeInTheDocument(); }); });