Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/server-tool-results-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai': patch
'@tanstack/ai-client': patch
'@tanstack/ai-event-client': patch
Comment on lines +2 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use minor bumps for this exported type-shape change.

Adding 'complete' to ToolCallState is a public shape change and should be released as minor for this repo’s pre-1.0 convention.

Suggested fix
-'`@tanstack/ai`': patch
-'`@tanstack/ai-client`': patch
-'`@tanstack/ai-event-client`': patch
+'`@tanstack/ai`': minor
+'`@tanstack/ai-client`': minor
+'`@tanstack/ai-event-client`': minor
Based on learnings: "In the TanStack/ai repository (pre-1.0), follow the repo’s versioning convention: breaking changes and breaking/shape changes documented in Changesets must use a `minor` version bump (not `major`)."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'@tanstack/ai': patch
'@tanstack/ai-client': patch
'@tanstack/ai-event-client': patch
'`@tanstack/ai`': minor
'`@tanstack/ai-client`': minor
'`@tanstack/ai-event-client`': minor
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/server-tool-results-complete.md around lines 2 - 4, The changeset
currently uses patch bumps for packages '`@tanstack/ai`', '`@tanstack/ai-client`',
and '`@tanstack/ai-event-client`' but the addition of 'complete' to the exported
ToolCallState type is a public type-shape change and must be released as a minor
bump per the repo's pre-1.0 convention; update the changeset entry to use
"minor" for those three packages (and ensure the changeset message references
the ToolCallState/complete change) so the release tooling will produce a minor
version rather than a patch.

---

Populate server-executed tool results on the matching `tool-call` part and mark successful tool calls as `complete`.
1 change: 1 addition & 0 deletions packages/typescript/ai-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ToolCallState =
| 'input-complete' // All arguments received
| 'approval-requested' // Waiting for user approval
| 'approval-responded' // User has approved/denied
| 'complete' // Result is complete

/**
* Tool result states - track the lifecycle of a tool result
Expand Down
1 change: 1 addition & 0 deletions packages/typescript/ai-event-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export type ToolCallState =
| 'input-complete' // All arguments received
| 'approval-requested' // Waiting for user approval
| 'approval-responded' // User has approved/denied
| 'complete' // Result is complete

/**
* Tool result states - track the lifecycle of a tool result
Expand Down
1 change: 1 addition & 0 deletions packages/typescript/ai/src/activities/chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function createSegment(): AssistantSegment {
function isToolCallIncluded(part: ToolCallPart): boolean {
return (
part.state === 'input-complete' ||
part.state === 'complete' ||
part.state === 'approval-responded' ||
part.output !== undefined
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,7 @@ export function updateToolCallWithOutput(

if (toolCallPart) {
toolCallPart.output = errorText ? { error: errorText } : output
if (state) {
toolCallPart.state = state
} else {
toolCallPart.state = 'input-complete'
}
toolCallPart.state = state ?? (errorText ? 'input-complete' : 'complete')
}

return { ...msg, parts }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export class StreamProcessor {
// 3. It has a corresponding tool-result part (server tool completed)
return toolParts.every(
(part) =>
part.state === 'complete' ||
part.state === 'approval-responded' ||
(part.output !== undefined && !part.approval) ||
toolResultIds.has(part.id),
Expand Down
1 change: 1 addition & 0 deletions packages/typescript/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type ToolCallState =
| 'input-complete' // All arguments received
| 'approval-requested' // Waiting for user approval
| 'approval-responded' // User has approved/denied
| 'complete' // Result is complete

/**
* Tool result states - track the lifecycle of a tool result
Expand Down
9 changes: 5 additions & 4 deletions packages/typescript/ai/tests/message-updaters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ describe('message-updaters', () => {
})

describe('updateToolCallWithOutput', () => {
it('should update tool call with output', () => {
it('should update tool call with output and complete state', () => {
const messages = [
createMessage('msg-1', 'assistant', [
{
Expand All @@ -558,7 +558,7 @@ describe('message-updaters', () => {
id: 'call-1',
name: 'getWeather',
arguments: '{}',
state: 'input-complete',
state: 'complete',
output,
})
})
Expand Down Expand Up @@ -588,7 +588,7 @@ describe('message-updaters', () => {
expect(part?.output).toEqual(output)
})

it('should default to input-complete state when not provided', () => {
it('should default to complete state when not provided', () => {
const messages = [
createMessage('msg-1', 'assistant', [
{
Expand All @@ -604,7 +604,7 @@ describe('message-updaters', () => {
const result = updateToolCallWithOutput(messages, 'call-1', output)

const part = result[0]?.parts[0] as ToolCallPart | undefined
expect(part?.state).toBe('input-complete')
expect(part?.state).toBe('complete')
})

it('should handle error output', () => {
Expand All @@ -629,6 +629,7 @@ describe('message-updaters', () => {

const part = result[0]?.parts[0] as ToolCallPart | undefined
expect(part?.output).toEqual({ error: 'Tool execution failed' })
expect(part?.state).toBe('input-complete')
})

it('should search across all messages', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/typescript/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,7 @@ describe('StreamProcessor', () => {
(p) => p.type === 'tool-call',
) as ToolCallPart
expect((toolCallPart as any).output).toEqual({ temp: 72 })
expect(toolCallPart.state).toBe('complete')

const toolResultPart = messages[0]?.parts.find(
(p) => p.type === 'tool-result',
Expand Down Expand Up @@ -981,6 +982,7 @@ describe('StreamProcessor', () => {
(p) => p.type === 'tool-call',
) as ToolCallPart
expect((toolCallPart as any).output).toEqual({ temp: 72 })
expect(toolCallPart.state).toBe('complete')

const toolResultPart = messages[0]!.parts.find(
(p) => p.type === 'tool-result',
Expand Down Expand Up @@ -1020,6 +1022,7 @@ describe('StreamProcessor', () => {
.getMessages()[0]!
.parts.find((p) => p.type === 'tool-call') as ToolCallPart
expect((toolCallPart as any).output).toEqual({ error: 'Network error' })
expect(toolCallPart.state).toBe('input-complete')

const toolResultPart = processor
.getMessages()[0]!
Expand Down Expand Up @@ -3326,6 +3329,7 @@ describe('StreamProcessor', () => {
(p) => p.type === 'tool-call',
) as ToolCallPart
expect((toolCallPart as any).output).toEqual({ temp: 72 })
expect(toolCallPart.state).toBe('complete')

const toolResultPart = messages[0]?.parts.find(
(p) => p.type === 'tool-result',
Expand Down
15 changes: 15 additions & 0 deletions testing/e2e/tests/tools-test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ export async function getToolCalls(
})
}

/**
* Parse the full UIMessage array from #messages-json-content.
*/
export async function getMessages(page: Page): Promise<Array<any>> {
return page.evaluate(() => {
const el = document.getElementById('messages-json-content')
if (!el) return []
try {
return JSON.parse(el.textContent || '[]')
} catch {
return []
}
})
}

/**
* Extract tool-call parts with parsed arguments from #messages-json-content.
*/
Expand Down
73 changes: 69 additions & 4 deletions testing/e2e/tests/tools-test/server-client-sequence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMetadata,
getEventLog,
getToolCalls,
getMessages,
} from './helpers'

/**
Expand Down Expand Up @@ -58,6 +59,73 @@ test.describe('Server-Client Sequence E2E Tests', () => {
expect(chartExecution).toBeTruthy()
})

test('server and client tool results populate tool-call output and complete state', async ({
page,
testId,
aimockPort,
}) => {
await selectScenario(page, 'sequence-server-client', testId, aimockPort)
await runTest(page)

await waitForTestComplete(page, 15000, 2)

await page.waitForFunction(
() => {
const messagesEl = document.getElementById('messages-json-content')
const messages = JSON.parse(messagesEl?.textContent || '[]')
const toolCalls = messages.flatMap((msg: any) =>
Comment on lines +72 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard JSON parsing inside waitForFunction to avoid flaky polling failures.

JSON.parse(messagesEl?.textContent || '[]') can throw during transient DOM updates and fail the wait early. Return false on parse failure so polling continues.

Suggested fix
     await page.waitForFunction(
       () => {
         const messagesEl = document.getElementById('messages-json-content')
-        const messages = JSON.parse(messagesEl?.textContent || '[]')
+        let messages: Array<any> = []
+        try {
+          messages = JSON.parse(messagesEl?.textContent || '[]')
+        } catch {
+          return false
+        }
         const toolCalls = messages.flatMap((msg: any) =>
           (msg.parts || []).filter((part: any) => part.type === 'tool-call'),
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await page.waitForFunction(
() => {
const messagesEl = document.getElementById('messages-json-content')
const messages = JSON.parse(messagesEl?.textContent || '[]')
const toolCalls = messages.flatMap((msg: any) =>
await page.waitForFunction(
() => {
const messagesEl = document.getElementById('messages-json-content')
let messages: Array<any> = []
try {
messages = JSON.parse(messagesEl?.textContent || '[]')
} catch {
return false
}
const toolCalls = messages.flatMap((msg: any) =>
(msg.parts || []).filter((part: any) => part.type === 'tool-call'),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/tests/tools-test/server-client-sequence.spec.ts` around lines 72
- 76, The inline function passed to page.waitForFunction can throw when
JSON.parse reads transient DOM content; update the anonymous function used in
waitForFunction (the one querying 'messages-json-content' and assigning
messages) to guard JSON.parse with a try/catch: if parsing fails return false so
the wait continues, otherwise proceed to compute toolCalls and return the
intended truthy value; in short, wrap JSON.parse(...) in try/catch and return
false on parse error to avoid flaky polling.

(msg.parts || []).filter((part: any) => part.type === 'tool-call'),
)

const fetchData = toolCalls.find(
(part: any) => part.name === 'fetch_data',
)
const displayChart = toolCalls.find(
(part: any) => part.name === 'display_chart',
)

return (
fetchData?.state === 'complete' &&
fetchData.output !== undefined &&
displayChart?.state === 'complete' &&
displayChart.output !== undefined
)
},
{ timeout: 10000 },
)

const messages = await getMessages(page)
const toolCalls = messages.flatMap((msg) =>
(msg.parts || []).filter((part: any) => part.type === 'tool-call'),
)
const toolResults = messages.flatMap((msg) =>
(msg.parts || []).filter((part: any) => part.type === 'tool-result'),
)

const fetchData = toolCalls.find((part) => part.name === 'fetch_data')
expect(fetchData).toMatchObject({
state: 'complete',
output: {
source: 'api',
data: [1, 2, 3, 4, 5],
},
})

const displayChart = toolCalls.find((part) => part.name === 'display_chart')
expect(displayChart?.state).toBe('complete')
expect(displayChart?.output).toMatchObject({ rendered: true })
expect(typeof displayChart?.output?.chartId).toBe('string')

expect(
toolResults.some(
(part) =>
part.toolCallId === fetchData?.id &&
part.state === 'complete' &&
JSON.parse(part.content).source === 'api',
),
).toBe(true)
})

test('server then two client tools in sequence', async ({
page,
testId,
Expand Down Expand Up @@ -162,10 +230,7 @@ test.describe('Server-Client Sequence E2E Tests', () => {
const toolCalls = await getToolCalls(page)
const weatherTool = toolCalls.find((tc) => tc.name === 'get_weather')
expect(weatherTool).toBeTruthy()
// Server tools stay at 'input-complete' state but are tracked as complete via tool-result parts
expect(['complete', 'input-complete', 'output-available']).toContain(
weatherTool?.state,
)
expect(weatherTool?.state).toBe('complete')
})

test('text only scenario has no tool calls', async ({
Expand Down
Loading