Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Support client-side tools in DurableAgent - #1329

Merged
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools
Mar 11, 2026
Merged

Support client-side tools in DurableAgent#1329
pranaygp merged 5 commits into
mainfrom
worktree-client-side-tools

Conversation

@pranaygp

@pranaygppranaygp commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tools without an execute function now pause the agent loop instead of throwing Tool X does not have an execute function
  • Result exposes toolCalls and toolResults matching the AI SDK's GenerateTextResult convention
  • Returned messages include the full conversation (assistant tool-call message + resolved tool results) so callers can resume seamlessly
  • Stream protocol already sends tool-input-available chunks for all tool calls via doStreamStep, so useChat sees client-side tool calls without any frontend changes

Usage

Defining a client-side tool

Define a tool without an execute function — just like in the AI SDK:

import{DurableAgent}from'@workflow/ai/agent';import{tool}from'ai';import{z}from'zod';constagent=newDurableAgent({model: 'anthropic/claude-sonnet',tools: {// Server-side tool — has execute, runs on the servergetWeather: tool({description: 'Get the weather for a location',parameters: z.object({location: z.string()}),execute: getWeatherStep,// a workflow step function}),// Client-side tool — no execute, handled by the frontendaskForConfirmation: tool({description: 'Ask the user to confirm an action',parameters: z.object({message: z.string(),action: z.string(),}),}),},});

Server-side: detecting unresolved tool calls

When the model calls a client-side tool, the agent loop pauses and returns. Check toolCalls vs toolResults to find unresolved calls — the same pattern as the AI SDK:

constresult=awaitagent.stream({
messages,writable: getWritable<UIMessageChunk>(),});// Find tool calls that weren't executed server-sideconstunresolvedToolCalls=result.toolCalls.filter((tc)=>!result.toolResults.some((tr)=>tr.toolCallId===tc.toolCallId));if(unresolvedToolCalls.length>0){// The agent paused — result.messages includes the full conversation// up to and including resolved tool results, ready for resumptionconsole.log('Waiting for client to resolve:',unresolvedToolCalls);}

Frontend: using with useChat

On the frontend, client-side tool calls appear automatically as message parts via useChat. Use addToolOutput to provide results:

const{ messages, addToolOutput }=useChat({transport: newWorkflowChatTransport({api: '/api/chat'}),});// Render tool calls that need user interaction{messages.map((message)=>message.parts?.filter((part)=>part.type==='tool-invocation'&&part.state==='call').map((part)=>(<ConfirmationDialogkey={part.toolInvocation.toolCallId}message={part.toolInvocation.args.message}onConfirm={()=>{addToolOutput({toolCallId: part.toolInvocation.toolCallId,output: {confirmed: true},});}}/>)))}

Motivation

Fixes#847. The AI SDK allows tools without execute functions for human-in-the-loop, client-side tool execution, and other patterns where tool results come from outside the server. DurableAgent previously threw an error for these tools; now it matches the AI SDK behavior by pausing the loop and exposing unresolved calls on the result.

Test plan

  • New test: tool without execute stops the loop, toolCalls has entry, toolResults is empty
  • New test: mixed executable + client-side tools — toolCalls has both, toolResults only has executed
  • New test: onFinish callback fires when stopping for client-side tools
  • New test: normal completion has empty toolCalls/toolResults
  • New test: returned messages reference iterMessages (includes assistant tool-call message)
  • All existing 35 DurableAgent tests continue to pass
  • Full @workflow/ai test suite passes (84 tests)
  • Build passes

🤖 Generated with Claude Code

Tools without an `execute` function now pause the agent loop and return
`clientToolCalls` in the result, enabling human-in-the-loop and
client-side tool execution patterns. Fixes#847.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-botBot commented Mar 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b4fb1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (-1.2%)1.005s (~)0.972s101.00x
💻 LocalNitro0.034s (+3.1%)1.006s (~)0.973s101.04x
💻 LocalNext.js (Turbopack)0.041s1.006s0.965s101.26x
🌐 RedisNext.js (Turbopack)0.048s (+12.4% 🔺)1.005s (~)0.957s101.48x
🐘 PostgresNext.js (Turbopack)0.050s1.011s0.961s101.54x
🐘 PostgresExpress0.051s (-4.1%)1.011s (~)0.960s101.58x
🐘 PostgresNitro0.053s (-3.8%)1.011s (~)0.958s101.64x
🌐 MongoDBNext.js (Turbopack)0.092s (+11.9% 🔺)1.009s (~)0.916s102.85x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express0.373s (-18.3% 🟢)1.933s (-19.0% 🟢)1.560s101.00x
▲ VercelNext.js (Turbopack)0.434s (-12.4% 🟢)2.192s (+2.6%)1.758s101.16x
▲ VercelNitro0.446s (-20.9% 🟢)3.765s (+57.8% 🔺)3.319s101.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.902s101.00x
💻 LocalExpress1.104s (~)2.005s (~)0.901s101.00x
💻 LocalNitro1.105s (~)2.006s (~)0.901s101.00x
🌐 RedisNext.js (Turbopack)1.114s (+1.6%)2.007s (~)0.893s101.01x
🐘 PostgresNitro1.129s (~)2.013s (~)0.883s101.02x
🐘 PostgresNext.js (Turbopack)1.131s2.013s0.882s101.03x
🐘 PostgresExpress1.137s (+0.7%)2.012s (~)0.875s101.03x
🌐 MongoDBNext.js (Turbopack)1.313s (+0.7%)2.010s (~)0.697s101.19x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.967s (~)6.414s (+75.4% 🔺)4.447s101.00x
▲ VercelExpress2.019s (-5.3% 🟢)6.078s (+61.5% 🔺)4.059s101.03x
▲ VercelNext.js (Turbopack)2.049s (-2.4%)9.851s (+176.2% 🔺)7.802s101.04x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)10.643s11.022s0.379s31.00x
🌐 RedisNext.js (Turbopack)10.671s (~)11.023s (~)0.352s31.00x
💻 LocalNitro10.775s (~)11.022s (~)0.247s31.01x
💻 LocalExpress10.784s (~)11.023s (~)0.239s31.01x
🐘 PostgresNext.js (Turbopack)10.813s11.044s0.231s31.02x
🐘 PostgresExpress10.826s (~)11.044s (~)0.218s31.02x
🐘 PostgresNitro10.876s (~)11.043s (~)0.167s31.02x
🌐 MongoDBNext.js (Turbopack)12.291s (+1.0%)13.022s (~)0.731s31.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.509s (-3.3%)29.623s (+57.8% 🔺)13.115s21.00x
▲ VercelExpress16.769s (-4.9%)30.494s (+57.8% 🔺)13.725s21.02x
▲ VercelNext.js (Turbopack)17.815s (+4.6%)37.548s (+101.7% 🔺)19.733s11.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.626s (~)27.051s (~)0.424s31.00x
💻 LocalNext.js (Turbopack)26.904s27.050s0.146s31.01x
🐘 PostgresExpress26.938s (~)27.059s (~)0.121s31.01x
🐘 PostgresNext.js (Turbopack)26.951s27.066s0.114s31.01x
🐘 PostgresNitro27.058s (~)27.731s (+1.2%)0.673s31.02x
💻 LocalExpress27.220s (~)28.052s (~)0.832s31.02x
💻 LocalNitro27.239s (~)28.052s (~)0.813s31.02x
🌐 MongoDBNext.js (Turbopack)30.549s (+1.0%)31.042s (~)0.493s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro43.802s (-5.5% 🟢)60.694s (+25.5% 🔺)16.892s11.00x
▲ VercelExpress43.830s (+0.7%)60.551s (+34.1% 🔺)16.721s11.00x
▲ VercelNext.js (Turbopack)48.634s (+9.9% 🔺)59.701s (+30.8% 🔺)11.067s21.11x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)53.213s (~)54.101s (~)0.888s21.00x
🐘 PostgresNext.js (Turbopack)53.767s54.112s0.345s21.01x
🐘 PostgresExpress53.803s (~)54.101s (~)0.298s21.01x
🐘 PostgresNitro54.047s (~)54.103s (~)0.057s21.02x
💻 LocalNext.js (Turbopack)55.557s56.098s0.541s21.04x
💻 LocalExpress56.099s (~)56.601s (+0.9%)0.503s21.05x
💻 LocalNitro56.126s (~)56.601s (+0.9%)0.475s21.05x
🌐 MongoDBNext.js (Turbopack)60.947s (+0.7%)61.083s (~)0.136s21.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro93.332s (-1.6%)119.403s (+24.2% 🔺)26.071s11.00x
▲ VercelNext.js (Turbopack)93.931s (-8.5% 🟢)119.574s (+13.8% 🔺)25.643s11.01x
▲ VercelExpress104.011s (+4.4%)120.067s (+18.1% 🔺)16.056s11.11x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.241s (-0.7%)2.006s (~)0.765s151.00x
🐘 PostgresExpress1.339s (-3.2%)2.010s (~)0.672s151.08x
🐘 PostgresNext.js (Turbopack)1.354s2.011s0.657s151.09x
🐘 PostgresNitro1.375s (~)2.012s (~)0.638s151.11x
💻 LocalNext.js (Turbopack)1.393s2.005s0.612s151.12x
💻 LocalExpress1.421s (+0.9%)2.005s (~)0.584s151.15x
💻 LocalNitro1.433s (+1.6%)2.006s (~)0.573s151.16x
🌐 MongoDBNext.js (Turbopack)2.140s (+1.2%)3.008s (~)0.868s101.72x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.062s (+15.6% 🔺)23.927s (+502.9% 🔺)20.866s21.00x
▲ VercelNitro3.065s (+1.5%)16.839s (+268.0% 🔺)13.774s31.00x
▲ VercelNext.js (Turbopack)6.233s (+118.5% 🔺)13.866s (+213.7% 🔺)7.633s42.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.474s (+25.0% 🔺)3.015s (+19.9% 🔺)0.540s101.00x
🐘 PostgresNitro2.482s (+18.8% 🔺)3.014s (+19.8% 🔺)0.533s101.00x
💻 LocalNext.js (Turbopack)2.526s3.008s0.482s101.02x
🌐 RedisNext.js (Turbopack)2.547s (+2.2%)3.008s (~)0.462s101.03x
🐘 PostgresNext.js (Turbopack)2.580s3.013s0.432s101.04x
💻 LocalExpress2.594s (-1.4%)3.007s (~)0.413s101.05x
💻 LocalNitro2.739s (+6.1% 🔺)3.009s (~)0.269s101.11x
🌐 MongoDBNext.js (Turbopack)4.711s (~)5.178s (~)0.467s61.90x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.992s (+4.7%)10.944s (+154.0% 🔺)7.952s51.00x
▲ VercelNitro3.256s (+34.1% 🔺)9.437s (+147.6% 🔺)6.181s51.09x
▲ VercelExpress6.528s (+160.6% 🔺)15.892s (+331.0% 🔺)9.364s32.18x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express4.022s (+12.7% 🔺)4.445s (+4.1%)0.423s71.00x
🌐 RedisNext.js (Turbopack)4.101s (+1.5%)4.869s (+6.3% 🔺)0.767s71.02x
🐘 PostgresNext.js (Turbopack)4.102s5.016s0.914s61.02x
🐘 PostgresNitro4.118s (+0.5%)4.592s (-3.2%)0.474s71.02x
💻 LocalNext.js (Turbopack)7.256s7.515s0.259s41.80x
💻 LocalExpress7.345s (-1.7%)8.018s (~)0.673s41.83x
💻 LocalNitro8.140s (+11.8% 🔺)9.022s (+12.5% 🔺)0.882s42.02x
🌐 MongoDBNext.js (Turbopack)9.880s (-0.9%)10.352s (~)0.471s32.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.332s (-15.7% 🟢)11.226s (+81.0% 🔺)7.894s51.00x
▲ VercelExpress5.578s (+82.9% 🔺)12.500s (+176.2% 🔺)6.922s61.67x
▲ VercelNext.js (Turbopack)6.991s (+86.0% 🔺)18.064s (+180.9% 🔺)11.073s32.10x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.246s (-1.0%)2.006s (~)0.760s151.00x
🐘 PostgresExpress1.347s (-3.5%)2.011s (~)0.664s151.08x
🐘 PostgresNext.js (Turbopack)1.358s2.012s0.653s151.09x
🐘 PostgresNitro1.359s (-1.1%)2.011s (~)0.652s151.09x
💻 LocalNext.js (Turbopack)1.395s2.005s0.610s151.12x
💻 LocalExpress1.452s (+1.6%)2.005s (~)0.553s151.17x
💻 LocalNitro1.456s (+1.6%)2.005s (~)0.550s151.17x
🌐 MongoDBNext.js (Turbopack)2.157s (-1.9%)3.008s (~)0.851s101.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.417s (-0.5%)13.901s (+282.2% 🔺)11.484s41.00x
▲ VercelExpress2.962s (+17.8% 🔺)18.998s (+310.5% 🔺)16.036s21.23x
▲ VercelNitro3.255s (+7.5% 🔺)10.884s (+126.1% 🔺)7.629s51.35x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express2.456s (+17.4% 🔺)3.011s (+19.8% 🔺)0.555s101.00x
🐘 PostgresNitro2.491s (+19.9% 🔺)3.014s (+19.8% 🔺)0.522s101.01x
🌐 RedisNext.js (Turbopack)2.524s (+0.8%)3.008s (~)0.484s101.03x
🐘 PostgresNext.js (Turbopack)2.568s3.013s0.445s101.05x
💻 LocalExpress2.752s (+3.0%)3.007s (~)0.255s101.12x
💻 LocalNext.js (Turbopack)2.806s3.110s0.303s101.14x
💻 LocalNitro2.807s (+2.4%)3.008s (~)0.201s101.14x
🌐 MongoDBNext.js (Turbopack)4.798s (+2.7%)5.178s (~)0.380s61.95x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.826s (-9.3% 🟢)14.232s (+197.4% 🔺)11.406s41.00x
▲ VercelNitro2.924s (+4.4%)13.721s (+216.3% 🔺)10.797s41.03x
▲ VercelNext.js (Turbopack)3.562s (+34.7% 🔺)14.106s (+253.1% 🔺)10.544s41.26x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.975s (+21.5% 🔺)4.447s (+10.6% 🔺)0.472s71.00x
🐘 PostgresNitro4.038s (+17.3% 🔺)4.588s (+7.3% 🔺)0.550s71.02x
🐘 PostgresNext.js (Turbopack)4.090s5.017s0.927s61.03x
🌐 RedisNext.js (Turbopack)4.104s (+2.8%)4.868s (+3.0%)0.764s71.03x
💻 LocalNext.js (Turbopack)7.238s7.769s0.532s41.82x
💻 LocalExpress7.935s (+0.9%)8.521s (+6.2% 🔺)0.586s42.00x
💻 LocalNitro8.148s (+1.3%)9.023s (+5.9% 🔺)0.875s42.05x
🌐 MongoDBNext.js (Turbopack)10.065s (+1.3%)10.686s (+3.3%)0.621s32.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.895s (-7.5% 🟢)11.081s (+133.9% 🔺)8.186s51.00x
▲ VercelNext.js (Turbopack)2.918s (+2.2%)10.955s (+166.5% 🔺)8.036s51.01x
▲ VercelNitro3.007s (+5.6% 🔺)13.729s (+235.0% 🔺)10.722s41.04x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.139s1.002s0.011s1.017s0.878s101.00x
🌐 RedisNext.js (Turbopack)0.143s (-2.1%)1.000s (~)0.002s (-5.9% 🟢)1.008s (~)0.865s101.03x
💻 LocalNitro0.173s (+0.6%)1.002s (~)0.011s (-0.9%)1.017s (~)0.844s101.25x
💻 LocalExpress0.175s (+0.6%)1.003s (~)0.012s (+4.5%)1.018s (~)0.843s101.26x
🐘 PostgresNext.js (Turbopack)0.186s1.001s0.002s1.013s0.826s101.34x
🐘 PostgresExpress0.195s (-2.5%)0.995s (~)0.002s (+20.0% 🔺)1.012s (~)0.817s101.40x
🐘 PostgresNitro0.203s (~)0.993s (~)0.002s (-6.3% 🟢)1.015s (~)0.812s101.46x
🌐 MongoDBNext.js (Turbopack)0.493s (+8.4% 🔺)0.951s (-3.5%)0.002s (+21.4% 🔺)1.009s (~)0.516s103.55x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.682s (-2.5%)11.242s (+347.5% 🔺)0.005s (+8.0% 🔺)11.731s (+279.1% 🔺)10.049s101.00x
▲ VercelNitro1.806s (+10.9% 🔺)11.589s (+335.7% 🔺)0.005s (+1.9%)12.153s (+276.9% 🔺)10.346s101.07x
▲ VercelNext.js (Turbopack)2.055s (+17.7% 🔺)6.751s (+173.2% 🔺)0.005s (-20.0% 🟢)11.617s (+273.9% 🔺)9.563s101.22x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)10/12
🐘 PostgresExpress7/12
▲ VercelExpress5/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres7/12
Next.js (Turbopack)🌐 Redis6/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production5294267638
✅ 💻 Local Development612084696
✅ 📦 Local Production612084696
❌ 🐘 Local Postgres611184696
✅ 🪟 Windows550358
❌ 🌍 Community Worlds1185615189
✅ 📋 Other147027174
Total2684993643147

❌ Failed Tests

▲ Vercel Production (42 failed)

astro (4 failed):

  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

example (5 failed):

  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • cancelRun - cancelling a running workflow
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

express (5 failed):

  • webhookWorkflow
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

fastify (3 failed):

  • webhookWorkflow
  • ChainableService.processWithThis - static step methods using this to reference the class
  • cancelRun - cancelling a running workflow

hono (4 failed):

  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-turbopack (6 failed):

  • promiseRaceWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • cancelRun - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nextjs-webpack (5 failed):

  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

nitro (2 failed):

  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow

nuxt (4 failed):

  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • cancelRun via CLI - cancelling a running workflow
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep

sveltekit (3 failed):

  • Calculator.calculate - static workflow method using static step methods from another class
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • cancelRun - cancelling a running workflow

vite (1 failed):

  • cancelRun - cancelling a running workflow
🐘 Local Postgres (1 failed)

sveltekit-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (56 failed)

mongodb (3 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

redis (2 failed):

  • hookWorkflow is not resumable via public webhook endpoint
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously

turso (51 failed):

  • addTenWorkflow
  • addTenWorkflow
  • wellKnownAgentWorkflow (.well-known/agent)
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • importedStepOnlyWorkflow
  • hookWorkflow
  • hookWorkflow is not resumable via public webhook endpoint
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control)

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
❌ astro4747
❌ example4657
❌ express4657
❌ fastify4837
❌ hono4747
❌ nextjs-turbopack5062
❌ nextjs-webpack5152
❌ nitro4927
❌ nuxt4747
❌ sveltekit4837
❌ vite5017
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
✅ sveltekit-stable4909
✅ vite-stable4909
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4909
✅ express-stable4909
✅ fastify-stable4909
✅ hono-stable4909
✅ nextjs-turbopack-canary5503
✅ nextjs-turbopack-stable5503
✅ nextjs-webpack-canary5503
✅ nextjs-webpack-stable5503
✅ nitro-stable4909
✅ nuxt-stable4909
❌ sveltekit-stable4819
✅ vite-stable4909
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5503
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev302
❌ mongodb5233
✅ redis-dev302
❌ redis5323
✅ turso-dev302
❌ turso4513
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4909
✅ e2e-local-postgres-nest-stable4909
✅ e2e-local-prod-nest-stable4909

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Comment threadpackages/ai/src/agent/durable-agent.ts Outdated
@pranaygp
pranaygp marked this pull request as ready for review March 11, 2026 19:10
@pranaygp
pranaygp requested a review from a team as a code ownerMarch 11, 2026 19:10
CopilotAI review requested due to automatic review settings March 11, 2026 19:10

CopilotAI left a comment

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.

Pull request overview

Updates DurableAgent to support AI SDK-style “client-side” tools (tools without execute) by pausing the agent loop and returning unresolved tool calls to the caller, rather than throwing.

Changes:

  • Add ClientToolCall type and clientToolCalls field to DurableAgentStreamResult.
  • Pause the loop when non-provider tool calls lack an execute function, while still executing any executable/provider tools in the same step.
  • Extract resolveProviderToolResult helper and add new test coverage for client-side tool behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

FileDescription
packages/ai/src/agent/durable-agent.tsImplements pause-and-return behavior for tools without execute, adds result typing, and refactors provider tool result resolution.
packages/ai/src/agent/durable-agent.test.tsAdds tests covering pause behavior, mixed tool steps, and onFinish behavior when pausing.
.changeset/client-side-tools.mdDeclares a patch release for the new client-side tools behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

toolCallId: 'ask-user-call-id',
toolName: 'askUser',
input: { question: 'What is your name?' },
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The new client-side tool tests validate clientToolCalls, but they don't assert that result.messages contains the assistant tool-call message needed to resume (or that server/provider tool results are preserved when pausing). Adding assertions around the returned messages shape/content for the pause cases would catch regressions where the agent returns only the original input messages.

Suggested change
});
});
// Result messages should match the messages returned by the iterator
expect(result.messages).toBeDefined();
expect(result.messages).toEqual(mockMessages);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +899 to +906
const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the client-side tool stop branch, messages is derived from (finalMessages ?? options.messages), but finalMessages is only set on normal iterator completion. When stopping early for client-side tools, this returns the original input messages and drops the assistant tool-call message from iterMessages, making it impossible for callers to resume by appending tool results to the correct tool calls. Return iterMessages (or a derived message list that includes the tool-call assistant message) instead of falling back to options.messages here, and ensure onFinish receives the same message list.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +834 to +841
const executableToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return tool && typeof tool.execute === 'function';
});
const clientSideToolCalls = nonProviderToolCalls.filter((tc) => {
const tool = (effectiveTools as ToolSet)[tc.toolName];
return !tool || typeof tool.execute !== 'function';
});

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

clientSideToolCalls currently includes tool calls where the tool is missing (!tool). That changes prior behavior (missing tools threw) and will cause the agent to pause and return an unresolved call for an unknown tool name (likely a model hallucination) instead of failing fast. Consider treating !tool as an error and only pausing for tools that exist but lack an execute function.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +873 to +877
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

When pausing for client-side tools, resolved executable/provider tool outputs are written directly to options.writable, but those chunks are not appended to allUIChunks. If collectUIMessages is enabled, the returned uiMessages will be missing these tool-output chunks (since uiChunks yielded by the iterator don't include tool outputs until after iterator.next(toolResults)). Add the written tool-output-available chunks to allUIChunks when collectUIChunks is true (or reuse the existing writeToolOutputToUI helper logic) so uiMessages reflects what was actually streamed.

Suggested change
awaitwriter.write({
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
}asUIMessageChunk);
constchunk: UIMessageChunk={
type: 'tool-output-available'asconst,
toolCallId: result.toolCallId,
output: result.output.value,
};
awaitwriter.write(chunk);
if(collectUIChunks){
allUIChunks.push(chunk);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Comment on lines +846 to +921
// Execute any executable tools that were also called in this step
const executableResults = await Promise.all(
executableToolCalls.map(
(toolCall): Promise<LanguageModelV2ToolResultPart> =>
executeTool(
toolCall,
effectiveTools as ToolSet,
iterMessages,
experimentalContext,
options.experimental_repairToolCall as ToolCallRepairFunction<ToolSet>
)
)
);

// Collect provider tool results
const providerResults: LanguageModelV2ToolResultPart[] =
providerToolCalls.map((toolCall) =>
resolveProviderToolResult(toolCall, providerExecutedToolResults)
);

// Combine executable and provider results, then write to UI
const resolvedResults = [...executableResults, ...providerResults];
if (resolvedResults.length > 0) {
// Write resolved tool results to the UI stream
const writer = options.writable.getWriter();
try {
for (const result of resolvedResults) {
await writer.write({
type: 'tool-output-available' as const,
toolCallId: result.toolCallId,
output: result.output.value,
} as UIMessageChunk);
}
} finally {
writer.releaseLock();
}
}

// Parse client-side tool call inputs
const parsedClientToolCalls: ClientToolCall[] =
clientSideToolCalls.map((tc) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: JSON.parse(tc.input || '{}'),
}));

// Close the stream and call onFinish before returning
const sendFinish = options.sendFinish ?? true;
const preventClose = options.preventClose ?? false;
if (sendFinish || !preventClose) {
await closeStream(options.writable, preventClose, sendFinish);
}

const messages = (finalMessages ??
options.messages) as unknown as ModelMessage[];

if (options.onFinish && !wasAborted) {
await options.onFinish({
steps,
messages: messages as ModelMessage[],
experimental_context: experimentalContext,
experimental_output: undefined as OUTPUT,
});
}

const uiMessages = collectUIChunks
? await convertChunksToUIMessages(allUIChunks)
: undefined;

return {
messages: messages as ModelMessage[],
steps,
experimental_output: undefined as OUTPUT,
uiMessages,
clientToolCalls: parsedClientToolCalls,
};

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

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

In the pause path, executable/provider tools are executed/resolved, but their results are neither appended to the returned messages nor exposed in the return value. This makes resuming hard/impossible (especially for provider-executed tool results, which the caller may not be able to reconstruct). Consider returning these resolved tool results (or returning an updated messages array that includes a role: 'tool' message with the resolved results) so the caller can resume without re-executing server tools and without losing provider results.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed in 16ea607.

Use `toolCalls` and `toolResults` on DurableAgentStreamResult to match
the AI SDK's GenerateTextResult convention. Consumers find unresolved
client-side tool calls by diffing toolCalls vs toolResults — the same
pattern used with useChat/addToolOutput.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Push directly to iterMessages (LanguageModelV2Prompt) instead of
double-casting through ModelMessage[]. Single cast at the return
point matches the existing pattern at the end of stream().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
>
): LanguageModelV2ToolResultPart {
const streamResult = providerExecutedToolResults?.get(toolCall.toolCallId);
if (streamResult) {

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.

nit: I would reverse the check and do an early exit to have less code indentation.

But not sure if agents care 🤣

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

boomer ;)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in 4b4fb1a — early exit with the warning/empty result, main path unindented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableAgent throws error for tools without execute function

3 participants

@pranaygp@gr2m