Skip to content

Commit e482c72

Browse files
committed
feat(react): lookup pending decision by id
1 parent a0155e4 commit e482c72

3 files changed

Lines changed: 133 additions & 9 deletions

File tree

packages/react/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ Currently exposes a single hook:
1212
The hook ships no UI. State lives in messages — there is no separate run
1313
store, no `runId`. Resumption after a tool decision re-POSTs to the same `api`
1414
endpoint with the augmented message log.
15+
16+
`respondWithDecision(toolCallId, value)` walks `messages` backwards to find
17+
the most recent assistant message that owns a `toolCall` block matching
18+
`toolCallId` with no decision attached, mutates that block, and re-POSTs.
19+
This means callers can append messages (system notes, status writes, etc.)
20+
to the log between the pause and the user's response — the lookup is by id,
21+
not position. Throws if no matching pending decision is found.

packages/react/__tests__/use-chat.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,109 @@ describe('useChat', () => {
335335
expect(result.current.pendingDecision).toBeUndefined();
336336
expect(result.current.isStreaming).toBe(false);
337337
});
338+
339+
it('finds the pending assistant by toolCallId when a later message was appended', async () => {
340+
// Simulates the queue-based architecture: a system note (modelled as a
341+
// user-role message) lands after the pause, so the trailing message is
342+
// not the assistant carrying the pending tool call.
343+
const assistantWithToolCall = makeAssistantWithToolCall();
344+
const trailingNote = makeUser('queued note arrived after pause', 99);
345+
const initial: Message[] = [
346+
makeUser('hi'),
347+
assistantWithToolCall,
348+
trailingNote,
349+
];
350+
const fetchFn = jest.fn(
351+
async (_url: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
352+
streamFromEvents([
353+
{ type: 'agent_start' },
354+
{ type: 'agent_end', messages: initial },
355+
])
356+
);
357+
358+
const { result } = renderHook(() =>
359+
useChat({ api: '/chat', fetch: fetchFn, initialMessages: initial })
360+
);
361+
362+
await act(async () => {
363+
await result.current.respondWithDecision('call_1', 'allow');
364+
});
365+
366+
const sent = JSON.parse(fetchFn.mock.calls[0][1]!.body as string);
367+
expect(sent.messages).toHaveLength(3);
368+
expect(sent.messages[1].content[0]).toMatchObject({
369+
type: 'toolCall',
370+
id: 'call_1',
371+
decision: 'allow',
372+
});
373+
// Trailing note is preserved in its position.
374+
expect(sent.messages[2]).toMatchObject({ role: 'user', content: 'queued note arrived after pause' });
375+
});
376+
377+
it('throws when no assistant has a pending decision for the toolCallId', async () => {
378+
const { result } = renderHook(() => useChat({ api: '/chat' }));
379+
380+
await expect(
381+
act(async () => {
382+
await result.current.respondWithDecision('call_unknown', 'allow');
383+
})
384+
).rejects.toThrow(/No pending decision for toolCallId 'call_unknown'/);
385+
});
386+
387+
it('skips assistants whose matching toolCall already has a decision', async () => {
388+
// Two assistants with different pending toolCallIds. Only the second
389+
// matches; the first should be ignored even though it has a decision
390+
// already attached for its own (unrelated) call.
391+
const earlierWithResolvedDecision = makeFakeAssistantMessage({
392+
stopReason: 'toolUse',
393+
content: [
394+
{
395+
type: 'toolCall',
396+
id: 'call_resolved',
397+
name: 'echo',
398+
arguments: { text: 'first' },
399+
rawArguments: '{"text":"first"}',
400+
decision: 'allow',
401+
},
402+
],
403+
});
404+
const laterPending = makeAssistantWithToolCall();
405+
const initial: Message[] = [
406+
makeUser('first'),
407+
earlierWithResolvedDecision,
408+
makeUser('second'),
409+
laterPending,
410+
];
411+
const fetchFn = jest.fn(
412+
async (_url: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
413+
streamFromEvents([
414+
{ type: 'agent_start' },
415+
{ type: 'agent_end', messages: initial },
416+
])
417+
);
418+
419+
const { result } = renderHook(() =>
420+
useChat({ api: '/chat', fetch: fetchFn, initialMessages: initial })
421+
);
422+
423+
await act(async () => {
424+
await result.current.respondWithDecision('call_1', 'allow');
425+
});
426+
427+
const sent = JSON.parse(fetchFn.mock.calls[0][1]!.body as string);
428+
// Earlier assistant's already-resolved decision is untouched.
429+
expect(sent.messages[1].content[0]).toMatchObject({
430+
type: 'toolCall',
431+
id: 'call_resolved',
432+
decision: 'allow',
433+
});
434+
// Later assistant's matching call gets the new decision.
435+
expect(sent.messages[3].content[0]).toMatchObject({
436+
type: 'toolCall',
437+
id: 'call_1',
438+
decision: 'allow',
439+
});
440+
});
338441
});
339442

340443
describe('error handling', () => {

packages/react/src/use-chat.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,24 +191,38 @@ export function useChat(options: UseChatOptions): UseChatResult {
191191
const respondWithDecision = useCallback(
192192
async (toolCallId: string, value: unknown): Promise<void> => {
193193
const current = messagesRef.current;
194-
if (current.length === 0) {
195-
throw new Error('No messages to attach a decision to');
194+
let targetIdx = -1;
195+
for (let i = current.length - 1; i >= 0; i--) {
196+
const msg = current[i];
197+
if (msg.role !== 'assistant') continue;
198+
const match = msg.content.find(
199+
(block) => block.type === 'toolCall' && block.id === toolCallId
200+
);
201+
if (!match) continue;
202+
if ('decision' in match && match.decision !== undefined) continue;
203+
targetIdx = i;
204+
break;
196205
}
197-
const lastIdx = current.length - 1;
198-
const last = current[lastIdx];
199-
if (last.role !== 'assistant') {
200-
throw new Error('Last message is not an assistant message; cannot attach a decision');
206+
if (targetIdx === -1) {
207+
throw new Error(
208+
`No pending decision for toolCallId '${toolCallId}'`
209+
);
201210
}
211+
const target = current[targetIdx] as AssistantMessage;
202212
const updatedAssistant: AssistantMessage = {
203-
...last,
204-
content: last.content.map((block) => {
213+
...target,
214+
content: target.content.map((block) => {
205215
if (block.type !== 'toolCall' || block.id !== toolCallId) {
206216
return block;
207217
}
208218
return { ...block, decision: value };
209219
}),
210220
};
211-
const requestMessages = [...current.slice(0, lastIdx), updatedAssistant];
221+
const requestMessages = [
222+
...current.slice(0, targetIdx),
223+
updatedAssistant,
224+
...current.slice(targetIdx + 1),
225+
];
212226
setMessages(requestMessages);
213227
messagesRef.current = requestMessages;
214228
setPendingDecision(undefined);

0 commit comments

Comments
 (0)