From 1e4461adff63e2b1794181b2f8430e486e56df33 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Sun, 18 Oct 2026 11:56:34 -0500 Subject: [PATCH 1/2] feat(ui): chat holds a tool call that changes something until the reader allows it --- packages/ui/README.md | 13 + .../adapter/openai/createOpenAIStream.test.ts | 52 ++ .../ai/adapter/openai/createOpenAIStream.ts | 3 + packages/ui/src/ai/adapter/openai/sse.ts | 20 +- packages/ui/src/ai/adapter/openai/wire.ts | 9 + packages/ui/src/ai/adapter/types.ts | 16 + .../ui/src/ai/components/ChatMessage.test.tsx | 58 +- packages/ui/src/ai/components/ChatMessage.tsx | 47 +- .../src/ai/components/ChatThread.stories.tsx | 67 +++ packages/ui/src/ai/components/ChatThread.tsx | 11 +- .../ui/src/ai/components/ComposerAddMenu.tsx | 18 +- packages/ui/src/ai/components/ShareDialog.tsx | 41 +- .../agent/AgentMessageParts.test.tsx | 23 + .../ai/components/agent/AgentMessageParts.tsx | 44 +- packages/ui/src/ai/core/ChatProvider.test.tsx | 79 +++ packages/ui/src/ai/core/ChatProvider.tsx | 522 +++++++++++------- packages/ui/src/ai/core/parts.test.ts | 16 + packages/ui/src/ai/core/parts.ts | 7 +- packages/ui/src/ai/stories/harness.tsx | 69 +++ packages/ui/src/ai/strings.tsx | 23 +- packages/ui/src/ai/types.ts | 7 +- 21 files changed, 925 insertions(+), 220 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 996f3b30..7cbbba82 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -865,6 +865,19 @@ OpenAI-compatible transport sends them as the `X-Platform-Tools` header (`TOOLS_HEADER`), comma-separated and empty when every tool is off. A request without a tool choice sends no header. +A tool that changes something sets `asksFirst`, which the menu shows, and its +calls wait for the reader. The gateway streams the call, then a `toolApproval` +chunk (`toolCallId`, optional `expiresAt`) in place of a result, and ends the +reply there. Chat shows a confirm under the call with Allow, Deny and Deny +with feedback, which expires at `expiresAt`. The answer goes back through +`useChat().answerToolApproval` as a request with no new user turn: +`StreamRequest.resume` (`messageId`, `toolCallId`, `allowed`, optional +`feedback`), sent as `resume` in the body. The gateway's `toolResult` for the +call lands on the reply that paused, and the model's answer streams as a new +reply under it. In a shared conversation only the person whose message +started the turn may answer; others see whom it waits on, and a turn someone +else started says whose access its tools ran with. + Tailwind v4 consumers add the chat rules (markdown rendering, streaming animations) and streamdown's classes next to the theme contract: diff --git a/packages/ui/src/ai/adapter/openai/createOpenAIStream.test.ts b/packages/ui/src/ai/adapter/openai/createOpenAIStream.test.ts index 807fb5ba..7373faf1 100644 --- a/packages/ui/src/ai/adapter/openai/createOpenAIStream.test.ts +++ b/packages/ui/src/ai/adapter/openai/createOpenAIStream.test.ts @@ -89,6 +89,58 @@ describe('createOpenAICompatibleStream', () => { expect(result.content).toBe('You have gpu-a.') }) + it('streams a call the gateway holds for approval as a waiting call with a confirm', async () => { + const fetchMock = vi.fn(async () => + sseResponse([ + 'data: {"id":"c","model":"m","messageId":"srv-1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"stop_cluster","arguments":"{}"}}]}}]}', + 'data: {"id":"c","model":"m","toolApproval":{"toolCallId":"call-1","expiresAt":"2026-10-18T12:00:00Z"}}', + 'data: {"id":"c","model":"m","choices":[{"delta":{},"finish_reason":"stop"}]}', + 'data: [DONE]', + ]), + ) + const stream = createOpenAICompatibleStream({ + completionsUrl: '/v1/chat/completions', + fetch: fetchMock, + }) + + const parts: PartDelta[] = [] + const result = await stream(baseRequest(), { + onContent: () => {}, + onPart: (delta) => parts.push(delta), + }) + + expect(parts).toEqual([ + { type: 'tool_start', id: 'call-1', name: 'stop_cluster', args: '{}' }, + { type: 'tool_wait', id: 'call-1' }, + { + type: 'approval_request', + approval: { + kind: 'approval', + id: 'call-1', + approvalKind: 'confirm', + subject: 'call', + canAllowDirectory: false, + expiresAt: '2026-10-18T12:00:00Z', + }, + }, + ]) + expect(result.toolCalls).toEqual([]) + }) + + it('sends the answer to a held call in the body', async () => { + const fetchMock = vi.fn(async () => sseResponse(['data: [DONE]'])) + const stream = createOpenAICompatibleStream({ + completionsUrl: '/v1/chat/completions', + fetch: fetchMock, + }) + const resume = { messageId: 'srv-1', toolCallId: 'call-1', allowed: false, feedback: 'Not now' } + + await stream(baseRequest({ resume }), { onContent: () => {} }) + + const body = JSON.parse(String(fetchMock.mock.calls[0]![1]?.body)) + expect(body.resume).toEqual(resume) + }) + it('returns tool calls the gateway left for the caller to run', async () => { const fetchMock = vi.fn(async () => sseResponse([ diff --git a/packages/ui/src/ai/adapter/openai/createOpenAIStream.ts b/packages/ui/src/ai/adapter/openai/createOpenAIStream.ts index 25ce46ce..d71368bd 100644 --- a/packages/ui/src/ai/adapter/openai/createOpenAIStream.ts +++ b/packages/ui/src/ai/adapter/openai/createOpenAIStream.ts @@ -52,6 +52,9 @@ export function createOpenAICompatibleStream(config: OpenAIStreamConfig): Stream const body: Record = useResponses ? buildResponsesRequestBody(req.model, req.messages) : { model: req.model, messages: req.messages, stream: true } + if (req.resume && !useResponses) { + body['resume'] = req.resume + } const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis) const res = await doFetch(url, { diff --git a/packages/ui/src/ai/adapter/openai/sse.ts b/packages/ui/src/ai/adapter/openai/sse.ts index b31e30e7..aa4ba431 100644 --- a/packages/ui/src/ai/adapter/openai/sse.ts +++ b/packages/ui/src/ai/adapter/openai/sse.ts @@ -1,6 +1,6 @@ import type { ToolCall, ToolCallDelta } from '../../types' import type { PartDelta } from '../types' -import type { CompletionChunk, ToolResultChunk } from './wire' +import type { CompletionChunk, ToolApprovalChunk, ToolResultChunk } from './wire' export type ParsedSSELine = { id?: string | undefined @@ -9,6 +9,7 @@ export type ParsedSSELine = { reasoningContent?: string | undefined toolCalls?: ToolCallDelta[] | undefined toolResult?: ToolResultChunk | undefined + toolApproval?: ToolApprovalChunk | undefined finishReason?: string | null | undefined model?: string | undefined error?: string | undefined @@ -39,6 +40,7 @@ export function parseSSELine(line: string): ParsedSSELine { reasoningContent: choice?.delta?.reasoning_content, toolCalls: choice?.delta?.tool_calls ?? undefined, toolResult: chunk.toolResult, + toolApproval: chunk.toolApproval, finishReason: choice?.finish_reason, model: chunk.model, } @@ -168,6 +170,22 @@ export async function processSSEStream( ...(parsed.toolResult.isError ? { isError: true } : {}), }) } + if (parsed.toolApproval) { + const { toolCallId, expiresAt } = parsed.toolApproval + answeredToolCalls.add(toolCallId) + onPart?.({ type: 'tool_wait', id: toolCallId }) + onPart?.({ + type: 'approval_request', + approval: { + kind: 'approval', + id: toolCallId, + approvalKind: 'confirm', + subject: 'call', + canAllowDirectory: false, + ...(expiresAt ? { expiresAt } : {}), + }, + }) + } } } } diff --git a/packages/ui/src/ai/adapter/openai/wire.ts b/packages/ui/src/ai/adapter/openai/wire.ts index fad377d3..4a31d32f 100644 --- a/packages/ui/src/ai/adapter/openai/wire.ts +++ b/packages/ui/src/ai/adapter/openai/wire.ts @@ -47,10 +47,19 @@ export interface CompletionChunk { messageId?: string // Gateway extension: the result of a tool call the gateway ran itself. toolResult?: ToolResultChunk + // Gateway extension: a tool call the gateway runs only once the user + // approves it. The turn ends here; the answer resumes it. + toolApproval?: ToolApprovalChunk choices?: CompletionChunkChoice[] | null usage?: TokenUsage } +export interface ToolApprovalChunk { + toolCallId: string + // ISO time after which the gateway refuses the approval. + expiresAt?: string +} + export interface ToolResultChunk { toolCallId: string content: string diff --git a/packages/ui/src/ai/adapter/types.ts b/packages/ui/src/ai/adapter/types.ts index 0259da7d..81c61951 100644 --- a/packages/ui/src/ai/adapter/types.ts +++ b/packages/ui/src/ai/adapter/types.ts @@ -49,6 +49,18 @@ export interface StreamRequest { // The ids of the host's tools the reader left on, possibly none. Absent // when the adapter lists no tools, or before the list has loaded. tools?: string[] + // Answers a tool call that waits for approval, in place of a new user + // turn: the gateway runs or declines it and the reply goes on from there. + resume?: ToolApprovalAnswer +} + +export interface ToolApprovalAnswer { + // The assistant message holding the call. + messageId: string + toolCallId: string + allowed: boolean + // Why the user declined, for the model. + feedback?: string } export interface StreamHandlers { @@ -75,6 +87,8 @@ export type PartDelta = startLine?: number } | { type: 'tool_update'; id: string; args: string } + // The call waits for the user's approval before it runs. + | { type: 'tool_wait'; id: string } | { type: 'tool_end'; id: string; result: string; isError?: boolean } | { type: 'subagent_start' @@ -171,6 +185,8 @@ export interface ChatToolOption { id: string label: string description?: string + // The tool changes something, so each call waits for the user's approval. + asksFirst?: boolean } export interface ChatToolsAdapter { diff --git a/packages/ui/src/ai/components/ChatMessage.test.tsx b/packages/ui/src/ai/components/ChatMessage.test.tsx index 97a908ce..b032c305 100644 --- a/packages/ui/src/ai/components/ChatMessage.test.tsx +++ b/packages/ui/src/ai/components/ChatMessage.test.tsx @@ -9,7 +9,13 @@ import ChatMessage from './ChatMessage' // by its own suite. vi.mock('./agent/AgentMessageParts', () => ({ __esModule: true, - default: () =>
, + default: ({ message, onApprovalAnswer }: { message: Message; onApprovalAnswer?: unknown }) => ( +
p.kind === 'approval')?.waitingOn ?? ''} + /> + ), })) // Mock the Markdown component @@ -648,3 +654,53 @@ describe('turn duration row', () => { expect(screen.queryByText('because of reasons')).not.toBeInTheDocument() }) }) + +describe('a turn someone else started', () => { + const bob = { id: 'u-bob', username: 'bob', name: 'Bob' } + const ask = createMockMessage({ id: 'ask', role: 'user', content: 'stop gpu-a', author: bob }) + const paused = createMockMessage({ + id: 'paused', + role: 'assistant', + parentId: 'ask', + content: '', + parts: [ + { kind: 'tool_call', id: 'c1', name: 'stop_cluster', args: '{}', status: 'waiting' }, + { kind: 'approval', id: 'c1', approvalKind: 'confirm', subject: 'call' }, + ], + }) + const answer = createMockMessage({ + id: 'answer', + role: 'assistant', + parentId: 'paused', + content: 'It is stopping.', + parts: [{ kind: 'tool_call', id: 'c2', name: 'list_clusters', args: '{}', status: 'ok' }], + }) + const props = { + ...defaultProps, + allMessages: [ask, paused, answer], + showAuthor: true, + onApprovalAnswer: vi.fn(), + } + + it('says whose access its tools ran with, through a resumed reply', () => { + render() + + expect(screen.getByText('Ran 1 tool call as Bob')).toBeInTheDocument() + }) + + it('leaves a held call to the person who asked for it', () => { + render() + + const parts = screen.getByTestId('agent-parts-mock') + expect(parts).toHaveAttribute('data-answerable', 'false') + expect(parts).toHaveAttribute('data-waiting-on', 'Bob') + }) + + it('lets the person who asked answer it', () => { + render() + + const parts = screen.getByTestId('agent-parts-mock') + expect(parts).toHaveAttribute('data-answerable', 'true') + expect(parts).toHaveAttribute('data-waiting-on', '') + }) +}) diff --git a/packages/ui/src/ai/components/ChatMessage.tsx b/packages/ui/src/ai/components/ChatMessage.tsx index 32492ed9..5a497a24 100644 --- a/packages/ui/src/ai/components/ChatMessage.tsx +++ b/packages/ui/src/ai/components/ChatMessage.tsx @@ -11,7 +11,7 @@ import { cx } from '../../cx' import { formatDuration } from '../../format' import { EditIcon, RetryIcon } from '../../icons' import { useChatConfig } from '../core/config' -import type { ApprovalAnswerValue, AttachmentRef, ChatMessage as Message } from '../types' +import type { ApprovalAnswerValue, AttachmentRef, ChatUser, ChatMessage as Message } from '../types' import Markdown from '../ui/Markdown' import { chatProseClasses } from '../ui/prose' import AgentMessageParts from './agent/AgentMessageParts' @@ -92,6 +92,14 @@ function ChatMessage({ ? message.author : undefined const isOtherUser = !!otherAuthor + // A reply in a turn someone else started: its tools ran with their access, + // and only they may answer a call waiting for approval. + const asker = + !isUser && showAuthor && currentUsername ? turnAuthor(message, allMessages) : undefined + const otherAsker = asker && asker.username !== currentUsername ? asker : undefined + const askerName = otherAsker ? otherAsker.name || otherAsker.username : undefined + const partsMessage = askerName ? waitingOn(message, askerName) : message + const approvalAnswer = askerName ? undefined : onApprovalAnswer // Right-align only for the current user's messages const isRightAligned = isUser && !isOtherUser const hasError = !!message.error @@ -182,9 +190,9 @@ function ChatMessage({ return (
) @@ -279,7 +287,9 @@ function ChatMessage({ {message.reasoningDuration ? tThinking.thoughtFor(formatDuration(message.reasoningDuration)) - : tThinking.ranToolCalls(toolCallCount)} + : askerName + ? tThinking.ranToolCallsAs(toolCallCount, askerName) + : tThinking.ranToolCalls(toolCallCount)}
@@ -289,9 +299,9 @@ function ChatMessage({ {!isUser && hasParts && (
)} @@ -476,3 +486,28 @@ function ChatMessage({ } export default memo(ChatMessage) + +/** The author of the user message that started the turn a reply belongs to. + * A reply that resumes after an approval hangs under the reply it resumes. */ +function turnAuthor(message: Message, allMessages: Message[]): ChatUser | undefined { + let parentId = message.parentId + // Bounded, so a malformed chain that loops cannot hang the render. + for (let hops = 0; parentId && hops < allMessages.length; hops++) { + const parent = allMessages.find((m) => m.id === parentId) + if (parent?.role !== 'assistant') { + return parent?.role === 'user' ? parent.author : undefined + } + parentId = parent.parentId + } + return undefined +} + +function waitingOn(message: Message, name: string): Message { + if (!message.parts?.some((p) => p.kind === 'approval')) { + return message + } + return { + ...message, + parts: message.parts.map((p) => (p.kind === 'approval' ? { ...p, waitingOn: name } : p)), + } +} diff --git a/packages/ui/src/ai/components/ChatThread.stories.tsx b/packages/ui/src/ai/components/ChatThread.stories.tsx index bb907a56..dfef3aff 100644 --- a/packages/ui/src/ai/components/ChatThread.stories.tsx +++ b/packages/ui/src/ai/components/ChatThread.stories.tsx @@ -4,12 +4,15 @@ import { createMockChatAdapter } from '../adapter/mock' import { makeAgentTurn, makeConversation, + makeHeldCallTurn, makeHybridTurn, makeLongConversation, makeStaticAdapter, + resumeHeldCall, StoryChat, StoryViewport, sendFromComposer, + storyTeammate, } from '../stories/harness' import ChatEmptyState from './ChatEmptyState' import ChatLayout from './ChatLayout' @@ -211,6 +214,70 @@ export const ToolCallConversation: StoryObj = { ), } +// A call that changes something waits for the reader: Allow resumes the +// reply with the call's result, Deny tells the model why. +export const HeldToolCall: StoryObj = { + render: () => ( + + + + + + ), +} + +// The same call in a shared conversation Bob started: it runs with Bob's +// access, so only Bob may answer it, and the share note says the results +// are visible to the people it is shared with. +export const HeldToolCallSomeoneElseAsked: StoryObj = { + render: () => ( + + + + + + ), +} + // Live pipeline: onPart deltas stream tool calls (running → resolved) between // the thinking header and the reply, and the finished message keeps them. export const StreamingToolCalls: StoryObj<{ diff --git a/packages/ui/src/ai/components/ChatThread.tsx b/packages/ui/src/ai/components/ChatThread.tsx index 44ad8b74..3e0e2795 100644 --- a/packages/ui/src/ai/components/ChatThread.tsx +++ b/packages/ui/src/ai/components/ChatThread.tsx @@ -9,7 +9,7 @@ import ChatMessageList from './ChatMessageList' import { ComposerControls, ComposerUsage, ConnectToolsLink } from './ComposerChrome' import DragOverlay from './DragOverlay' import ProviderIssueBanner from './ProviderIssueBanner' -import { ShareButton } from './ShareDialog' +import { ShareButton, ToolResultsSharedNote } from './ShareDialog' interface EditingState { messageId: string @@ -28,6 +28,8 @@ export default function ChatThread({ conversationId }: { conversationId: string loadProviders, sendMessage, retryMessage, + answerToolApproval, + tools, isLoadingConversation, navigateToBranch, queuedMessages, @@ -203,6 +205,9 @@ export default function ChatThread({ conversationId }: { conversationId: string } onSaveEdit={handleSaveEdit} onCancelEdit={handleCancelEdit} + {...(currentConversation?.canCollaborate !== false + ? { onApprovalAnswer: answerToolApproval } + : {})} inputElement={ - {currentConversation?.isOwner !== false && ( + {currentConversation?.isOwner !== false ? ( + ) : ( + tools.length > 0 && )} diff --git a/packages/ui/src/ai/components/ComposerAddMenu.tsx b/packages/ui/src/ai/components/ComposerAddMenu.tsx index 5c5da52a..f7d2335f 100644 --- a/packages/ui/src/ai/components/ComposerAddMenu.tsx +++ b/packages/ui/src/ai/components/ComposerAddMenu.tsx @@ -95,10 +95,21 @@ function RowSwitch() { ) } +function AsksFirst({ tool }: { tool: ChatToolOption }) { + const t = useChatConfig().strings.composerMenu + if (!tool.asksFirst) { + return null + } + return {t.asksFirst} +} + function ToolText({ tool, descriptionId }: { tool: ChatToolOption; descriptionId?: string }) { return ( - {tool.label} + + {tool.label} + + {tool.description && ( {tool.description} @@ -181,7 +192,10 @@ function ToolsModalRow({