From 4595a1cec717cee43600bc89bb2b2e40424c3bc5 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Wed, 13 May 2026 22:11:48 +0700 Subject: [PATCH 1/2] fix(react): close three useChat gaps surfaced by PR #5 review - send(): sync messagesRef before runStream so rapid synchronous sends both reach the outgoing request body - useState init: hydrate pendingDecisions from initialMessages so rehydrated paused tool calls render decision UI immediately - unmount: abort the in-flight fetch on cleanup to prevent leaked streams when the consumer unmounts mid-request Co-Authored-By: Claude Opus 4.7 --- packages/react/src/use-chat.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react/src/use-chat.ts b/packages/react/src/use-chat.ts index 512fbfb..a60a180 100644 --- a/packages/react/src/use-chat.ts +++ b/packages/react/src/use-chat.ts @@ -94,7 +94,7 @@ export function useChat(options: UseChatOptions): UseChatResult { const [isStreaming, setIsStreaming] = useState(false); const [pendingDecisions, setPendingDecisions] = useState< ReadonlyMap - >(() => new Map()); + >(() => rederivePendingDecisions(options.initialMessages ?? [])); const [executingToolCallIds, setExecutingToolCallIds] = useState>( () => new Set() ); @@ -293,6 +293,7 @@ export function useChat(options: UseChatOptions): UseChatResult { const userMessage: Message = typeof input === 'string' ? createUserMessage(input) : input; const requestMessages = [...messagesRef.current, userMessage]; + messagesRef.current = requestMessages; await runStream(requestMessages, userMessage); }, [runStream] @@ -358,6 +359,13 @@ export function useChat(options: UseChatOptions): UseChatResult { [] ); + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }; + }, []); + const abort = useCallback(() => { abortControllerRef.current?.abort(); abortControllerRef.current = null; From 50ced7b9638dd91ba41d950a8a58ad359d8df705 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Wed, 13 May 2026 22:11:57 +0700 Subject: [PATCH 2/2] test: lock down regressions surfaced by PR #5 review Adds four regression tests that act as acceptance criteria for the fixes shipped in PR #5 and the companion useChat fixes in this branch. agent.test.ts: - injectDeferralResults() + prompt() places the synthetic toolResult adjacent to its assistant block (verifies the documented "user typed instead of deciding" recovery pattern produces provider-valid order). use-chat.test.ts: - initialMessages with a paused tool call hydrates pendingDecisions. - Two rapid synchronous send() calls both reach the outgoing body. - Unmount aborts the in-flight fetch. Co-Authored-By: Claude Opus 4.7 --- packages/agent/__tests__/agent.test.ts | 48 +++++++++++++++ packages/react/__tests__/use-chat.test.ts | 73 +++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/agent/__tests__/agent.test.ts b/packages/agent/__tests__/agent.test.ts index d0c7d92..9126056 100644 --- a/packages/agent/__tests__/agent.test.ts +++ b/packages/agent/__tests__/agent.test.ts @@ -2,6 +2,7 @@ import { type AssistantMessage, type Context, createAssistantMessageEventStream, + injectDeferralResults, type Message, type ModelDescriptor, type ToolCallContent, @@ -372,6 +373,53 @@ describe('@agentic-kit/agent — pausable tools', () => { expect(execute).not.toHaveBeenCalled(); }); + it('injectDeferralResults + prompt() places the synthetic toolResult adjacent to its assistant block', async () => { + const provider = createScriptedProvider({ + responses: [pauseResponse(), finalResponse()], + }); + const execute = jest.fn(async () => ({ + content: [{ type: 'text' as const, text: 'should not run' }], + })); + + const agent = new Agent({ + initialState: { model: makeFakeModel() }, + streamFn: provider.stream, + }); + agent.setTools([makeApprovalTool(execute)]); + + await agent.prompt('approve thing'); + + const withDeferrals = injectDeferralResults(agent.state.messages); + agent.replaceMessages(withDeferrals); + + const typed: Message = { + role: 'user', + content: 'never mind', + timestamp: Date.now(), + }; + await agent.prompt(typed); + + const messages = agent.state.messages; + const assistantIdx = messages.findIndex( + (m) => + m.role === 'assistant' && + (m as AssistantMessage).content.some( + (b) => b.type === 'toolCall' && b.id === 'tool_1' + ) + ); + const toolResultIdx = messages.findIndex( + (m) => m.role === 'toolResult' && m.toolCallId === 'tool_1' + ); + const typedIdx = messages.findIndex( + (m) => m.role === 'user' && m.content === 'never mind' + ); + + expect(assistantIdx).toBeGreaterThanOrEqual(0); + expect(toolResultIdx).toBe(assistantIdx + 1); + expect(typedIdx).toBe(toolResultIdx + 1); + expect(execute).not.toHaveBeenCalled(); + }); + it('abort() while paused stops further work without throwing', async () => { const provider = createScriptedProvider({ responses: [pauseResponse()] }); diff --git a/packages/react/__tests__/use-chat.test.ts b/packages/react/__tests__/use-chat.test.ts index 7616cce..93cd59a 100644 --- a/packages/react/__tests__/use-chat.test.ts +++ b/packages/react/__tests__/use-chat.test.ts @@ -54,6 +54,49 @@ describe('useChat', () => { expect(result.current.executingToolCallIds.size).toBe(0); }); + it('hydrates pendingDecisions from initialMessages when a paused tool call is present', () => { + const initial: Message[] = [ + makeUser('hi'), + makeAssistantWithToolCall('call_pending'), + ]; + + const { result } = renderHook(() => + useChat({ api: '/chat', initialMessages: initial }) + ); + + expect(result.current.pendingDecisions.has('call_pending')).toBe(true); + expect(result.current.pendingDecisions.get('call_pending')).toMatchObject({ + toolCallId: 'call_pending', + toolName: 'echo', + }); + }); + + it('send(): two rapid synchronous sends both reach the outgoing request body', async () => { + const final = makeFinalAssistant('ok'); + const fetchFn = jest.fn( + async (_url: RequestInfo | URL, _init?: RequestInit): Promise => + streamFromEvents([ + { type: 'agent_start' }, + { type: 'agent_end', messages: [makeUser('first'), makeUser('second'), final] }, + ]) + ); + + const { result } = renderHook(() => useChat({ api: '/chat', fetch: fetchFn })); + + await act(async () => { + const p1 = result.current.send('first'); + const p2 = result.current.send('second'); + await Promise.allSettled([p1, p2]); + }); + + const lastInit = fetchFn.mock.calls.at(-1)![1] as RequestInit; + const sent = JSON.parse(lastInit.body as string); + const contents = sent.messages.map((m: Message) => + typeof m.content === 'string' ? m.content : null + ); + expect(contents).toEqual(['first', 'second']); + }); + it('sends, streams, and folds messages into the log', async () => { const final = makeFinalAssistant('world'); const userEcho = makeUser('hello'); @@ -656,6 +699,36 @@ describe('useChat', () => { expect(result.current.isStreaming).toBe(false); }); + it('unmount aborts the in-flight fetch', async () => { + let capturedSignal: AbortSignal | undefined; + const fetchFn = jest.fn( + (_url: RequestInfo | URL, init?: RequestInit): Promise => { + capturedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }); + }); + } + ); + + const { result, unmount } = renderHook(() => + useChat({ api: '/chat', fetch: fetchFn }) + ); + + act(() => { + void result.current.send('hi'); + }); + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + expect(capturedSignal?.aborted).toBe(false); + + unmount(); + + expect(capturedSignal?.aborted).toBe(true); + }); + it('drops events that arrive after abort', async () => { let pushFn!: (event: AgentEvent) => void; let closeFn!: () => void;