Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,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:

Expand Down
52 changes: 52 additions & 0 deletions packages/ui/src/ai/adapter/openai/createOpenAIStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(async () =>
sseResponse([
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/ai/adapter/openai/createOpenAIStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export function createOpenAICompatibleStream(config: OpenAIStreamConfig): Stream
const body: Record<string, unknown> = 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, {
Expand Down
20 changes: 19 additions & 1 deletion packages/ui/src/ai/adapter/openai/sse.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 } : {}),
},
})
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/ai/adapter/openai/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/ai/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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 {
Expand All @@ -76,6 +88,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: 'tool_decision'; id: string; decision: ToolDecision }
| {
Expand Down Expand Up @@ -173,6 +187,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 {
Expand Down
58 changes: 57 additions & 1 deletion packages/ui/src/ai/components/ChatMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import ChatMessage from './ChatMessage'
// by its own suite.
vi.mock('./agent/AgentMessageParts', () => ({
__esModule: true,
default: () => <div data-testid="agent-parts-mock" />,
default: ({ message, onApprovalAnswer }: { message: Message; onApprovalAnswer?: unknown }) => (
<div
data-testid="agent-parts-mock"
data-answerable={String(!!onApprovalAnswer)}
data-waiting-on={message.parts?.find((p) => p.kind === 'approval')?.waitingOn ?? ''}
/>
),
}))

// Mock the Markdown component
Expand Down Expand Up @@ -649,6 +655,56 @@ describe('turn duration row', () => {
})
})

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(<ChatMessage {...props} message={answer} currentUsername="ada" />)

expect(screen.getByText('Ran 1 tool call as Bob')).toBeInTheDocument()
})

it('leaves a held call to the person who asked for it', () => {
render(<ChatMessage {...props} message={paused} currentUsername="ada" />)

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(<ChatMessage {...props} message={paused} currentUsername="bob" />)

const parts = screen.getByTestId('agent-parts-mock')
expect(parts).toHaveAttribute('data-answerable', 'true')
expect(parts).toHaveAttribute('data-waiting-on', '')
})
})

describe("an agent's reply", () => {
const reply = createMockMessage({
id: 'turn-1',
Expand Down
47 changes: 41 additions & 6 deletions packages/ui/src/ai/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { formatDuration } from '../../format'
import { EditIcon, RetryIcon } from '../../icons'
import { useChatConfig } from '../core/config'
import { replyText } from '../core/useKeyboardShortcuts'
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'
Expand Down Expand Up @@ -93,6 +93,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
Expand Down Expand Up @@ -213,9 +221,9 @@ function ChatMessage({
return (
<div className={cx('group py-3 w-full', !flush && 'px-4')}>
<AgentMessageParts
message={message}
message={partsMessage}
isStreaming={isStreaming}
onApprovalAnswer={onApprovalAnswer}
onApprovalAnswer={approvalAnswer}
/>
{!isUser && !isStreaming && (said || onRegenerate || meta) && (
<div className="chat-actions -ml-1.5 mt-2 flex items-center gap-0.5">
Expand Down Expand Up @@ -338,7 +346,9 @@ function ChatMessage({
<span>
{message.reasoningDuration
? tThinking.thoughtFor(formatDuration(message.reasoningDuration))
: tThinking.ranToolCalls(toolCallCount)}
: askerName
? tThinking.ranToolCallsAs(toolCallCount, askerName)
: tThinking.ranToolCalls(toolCallCount)}
</span>
</div>
</div>
Expand All @@ -348,9 +358,9 @@ function ChatMessage({
{!isUser && hasParts && (
<div className="mb-1">
<AgentMessageParts
message={message}
message={partsMessage}
isStreaming={isStreaming}
onApprovalAnswer={onApprovalAnswer}
onApprovalAnswer={approvalAnswer}
/>
</div>
)}
Expand Down Expand Up @@ -509,3 +519,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)),
}
}
Loading
Loading