diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index acd8321..21718d2 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -525,3 +525,13 @@ Sınırlar: Bölümler arasına yayılan kanıt insan kontrolü gerektirebilir. Testler: gerçek toplama fonksiyonunda 100 kalem + 80 açık koşul; denetim yürütücüsünde 100 gereklilik, 60.000 karakter sonrasındaki kanıt, bounded çağrı boyutu ve başarısız bölümün belirsizliğe dönüşmesi. Model yanıtları bu testte sentetiktir; gerçek sağlayıcı ve uzun tarayıcı kabulü değildir. Sonuç: Tüm 114 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tamamı bitmedi; canlı yayın yapılmadı. + +### D06 / D09 — Uzun AI denetiminde bağlantı iptali + +Önceden istemci zaman aşımına uğrasa veya teklif asistanı kapansa da sunucu kalan denetim bölümlerini ve sağlayıcı yedeklerini çalıştırabiliyordu. Teklif Asistanı bileşeni kapanınca kendi HTTP isteğini iptal eder. Sunucu teklif araçlarında yanıt bağlantısının erken kapanması bir iptal sinyali üretir; normal istek gövdesinin bitmesi iptal sayılmaz. + +Sinyal soru/denetim/taslak çağrılarından AI sağlayıcı fetch'lerine taşınır. İptal sonrasında yedek sağlayıcı denenmez; çok parçalı denetim ve genel analiz yeni gruplara devam etmez, iptal kısmi başarı gibi dönmez. Dinleyiciler ve zamanlayıcılar tamamlanmada temizlenir. Sağlayıcıya gönderilmiş işlemin sağlayıcı tarafında ücretlendirilmesinin/hesaplanmasının geri alınması garanti edilmez. + +Testler: gerçek yerel HTTP isteğinde gövde tamamlandıktan sonra bağlantıyı keserek iptal; sağlayıcı fetch'ini iptal edip tek çağrı ve sıfır yedek deneme; sonraki denetim gruplarının durması; görünüm iptalinde istemci fetch'inin sonlanması. Sağlayıcı yanıtları ağ dışına çıkmayan test taklididir. Tüm 118 test ve üretim derlemesi geçti. + +Sınırlar: İlan/doküman kaynak ediniminin kendi alt istekleri ve OCR bu iptal zincirinin tamamına henüz bağlı değil. Kalıcı iş kuyruğu, yeniden başlatmada sürdürme ve tarayıcıdan uzun başarılı denetim kabulü açık. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı. diff --git a/server/ai-teklif.mjs b/server/ai-teklif.mjs index 2923639..00f9020 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -57,7 +57,7 @@ export async function ihaleMetni(ikn, verilen, ilan) { return { text: '', kaynak: null }; } -export async function belgeyeSor({ ikn, soru, text, tender, chain }) { +export async function belgeyeSor({ ikn, soru, text, tender, chain, signal }) { const q = String(soru ?? '').trim().slice(0, 500); if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.'); const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama); @@ -67,7 +67,7 @@ export async function belgeyeSor({ ikn, soru, text, tender, chain }) { if (!kaynaklar.length) return { cevap: 'Terim aramasında bu soruyla eşleşen bir bölüm bulunamadı; bu, şartın belgede bulunmadığını kanıtlamaz; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak }; const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik}`).join('\n\n'); const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Seçilen kaynak parçalarında bu bilgi bulunamadı" de ve kontrol edilen parçalara atıf ver, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`; - const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, systemOverride: sistem }); + const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, signal, systemOverride: sistem }); return {...qualifyQuestionAnswer(out?.result, kaynaklar), kaynak, sourceReview:{sourceHash:createHash('sha256').update(metin).digest('hex'), totalChunks:parcalar.length, selectedChunks:kaynaklar.length}, kapsam:'Yanıt yalnız soruyla eşleşen kaynak parçalarına dayanır. Kaynak numarası kontrolü, yanıtın anlam bakımından doğruluğunu tek başına kanıtlamaz; diğer bölümlerde ek veya çelişen şartlar bulunabilir.'}; @@ -92,7 +92,7 @@ export function gereklilikMetni(tender, gereklilikler, metin, allItems = false) return [...new Set(g)].slice(0, allItems ? undefined : 60); } -export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) { +export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text, signal }) { const t = String(taslak ?? '').trim(); if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); @@ -101,9 +101,9 @@ export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür; gereklilik alanına verilen gereklilik metnini değiştirmeden aynen kopyala.`; return auditOfferInParts(liste,t,async (group,draftPart) => { const out = await analyzeSpec({text:`GEREKLİLİKLER:\n${group.map((g,i)=>`${i+1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI BÖLÜMÜ:\n${draftPart}\n\nYalnızca JSON döndür.`, - tender:tender ? {...tender,items:[]} : undefined,chain,systemOverride:sistem,mode:'derin',_singlePart:true}); + tender:tender ? {...tender,items:[]} : undefined,chain,systemOverride:sistem,mode:'derin',signal,_singlePart:true}); return out?.result; - }); + },{signal}); } const BOLUMLER = { @@ -113,7 +113,7 @@ const BOLUMLER = { tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'], }; -export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) { +export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text, signal }) { const istenen = BOLUMLER[tur] || BOLUMLER.tam; const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama); const liste = gereklilikMetni(tender, gereklilikler, metin); @@ -125,6 +125,6 @@ export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, g fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null, ].filter(Boolean).join('\n\n'); const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver; bölüm başlıklarını değiştirmeden aynen kopyala.`; - const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' }); + const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, signal, systemOverride: sistem, mode: 'derin' }); return qualifyOfferDraft(out?.result, istenen); } diff --git a/server/ai.mjs b/server/ai.mjs index f95ffe9..2eea7aa 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -317,11 +317,12 @@ function kesikJsonOnar(s) { } /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ -export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, _singlePart = false }) { +export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, signal, _singlePart = false }) { + signal?.throwIfAborted(); if (!_singlePart && String(text || '').length > 24000) { - return analyzeInParts({text,fileName,tender,mode,extraction}, part => analyzeSpec({ + return analyzeInParts({text,fileName,tender,mode,extraction,signal}, part => analyzeSpec({ text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`, - tender:tender ? {...tender,items:[]} : undefined,chain,mode,_singlePart:true, + tender:tender ? {...tender,items:[]} : undefined,chain,mode,signal,_singlePart:true, systemOverride:(systemOverride || (mode === 'derin' ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT)) + '\nBu metin büyük dokümanın bir bölümüdür. Yalnız bu bölümde görülen koşulları çıkar; bu bölümde bulunmayan şartın bütün dokümanda bulunmadığı sonucunu çıkarma.', })); } @@ -342,6 +343,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid const attempts = []; for (const id of order) { + signal?.throwIfAborted(); const p = PROVIDERS[id]; const route = routeFor(id); if (!route) { @@ -349,6 +351,8 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid continue; } const ac = new AbortController(); + const cancel = () => ac.abort(); + signal?.addEventListener('abort',cancel,{once:true}); const timer = setTimeout(() => ac.abort(), timeoutMs); try { let raw; @@ -361,6 +365,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid } else { raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } + signal?.throwIfAborted(); const parsed = extractJson(raw); const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000, expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true}); @@ -372,9 +377,10 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid attempts, }; } catch (e) { + signal?.throwIfAborted(); attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); } finally { - clearTimeout(timer); + clearTimeout(timer);signal?.removeEventListener('abort',cancel); } } const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', ')); diff --git a/server/analysis-parts.mjs b/server/analysis-parts.mjs index fb38169..a36c58c 100644 --- a/server/analysis-parts.mjs +++ b/server/analysis-parts.mjs @@ -17,6 +17,7 @@ export async function analyzeInParts(input,runPart) { const parts=splitAnalysisText(input.text),results=[],failures=[]; // Two requests at a time bounds provider pressure while processing every part. for(let i=0;i{ try { const out=await runPart(part); @@ -26,6 +27,7 @@ export async function analyzeInParts(input,runPart) { catch(error){failures.push({part,error:String(error.message || error)});} })); } + input.signal?.throwIfAborted(); results.sort((a,b)=>a.part.index-b.part.index); if(!results.length)throw new Error('Dokümanın hiçbir parçası analiz edilemedi.'); const merged={},issues=failures.map(f=>`${f.part.index+1}. parça analiz edilemedi.`); diff --git a/server/index.js b/server/index.js index 728ebad..e449ed0 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import { requestLifetime } from './request-lifetime.mjs'; import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs'; import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs'; import { deadlineInfo } from '../src/services/tenderCalendar.js'; @@ -1457,9 +1458,10 @@ const server = http.createServer(async (req, res) => { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.' }); + const lifetime = requestLifetime(req,res); try { const body = await readBody(req, 2 * 1024 * 1024); - const ortak = { ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text }; + const ortak = { signal:lifetime.signal, ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text }; const out = url.pathname === '/api/ai/belge-sor' ? await belgeyeSor({ ...ortak, soru: body.soru }) : url.pathname === '/api/ai/teklif-denetim' @@ -1467,8 +1469,9 @@ const server = http.createServer(async (req, res) => { : await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler }); return json(res, 200, { success: true, ...out }); } catch (e) { + if (lifetime.signal.aborted) return; return json(res, e.attempts ? 502 : 400, { success: false, error: e.message }); - } + } finally { lifetime.dispose(); } } if (url.pathname === '/api/tenders') { diff --git a/server/offer-audit-parts.mjs b/server/offer-audit-parts.mjs index a7f4f5c..2cb16e1 100644 --- a/server/offer-audit-parts.mjs +++ b/server/offer-audit-parts.mjs @@ -2,7 +2,8 @@ import {splitAnalysisText} from './analysis-parts.mjs'; import {qualifyOfferAudit} from './offer-audit.mjs'; /** Audit every supplied requirement against every draft part, without generic AI-schema merging. */ -export async function auditOfferInParts(requirements, draft, run) { +export async function auditOfferInParts(requirements, draft, run, {signal} = {}) { + signal?.throwIfAborted(); const groups=[];let current=[],size=0; for(const requirement of requirements) { if(requirement.length>8000) throw new Error('Tek bir denetim gerekliliği çok uzun. Kaynak maddeyi ayrı gerekliliklere ayırın.'); @@ -11,12 +12,13 @@ export async function auditOfferInParts(requirements, draft, run) { } if(current.length)groups.push(current); const parts=splitAnalysisText(draft,10000),jobs=groups.flatMap((group,g)=>parts.map(part=>({group,g,part}))),results=[]; - for(let i=0;i{ + for(let i=0;i{ try { const output=await run(job.group,job.part.text); results.push({...job,review:qualifyOfferAudit(output,job.group,job.part.text)}); } catch(error){results.push({...job,error:String(error.message || error)});} - })); + })); } + signal?.throwIfAborted(); if(!results.some(result=>result.review))throw new Error('Taslağın hiçbir bölümü denetlenemedi. Yeniden deneyin.'); const merged=[]; for(const [g,group] of groups.entries()) for(const requirement of group) { diff --git a/server/request-lifetime.mjs b/server/request-lifetime.mjs new file mode 100644 index 0000000..3142598 --- /dev/null +++ b/server/request-lifetime.mjs @@ -0,0 +1,9 @@ +/** Request completion is normal; premature response closure cancels dependent work. */ +export function requestLifetime(req, res) { + const controller = new AbortController(); + const abort = () => controller.abort(); + const closed = () => {if (!res.writableFinished) abort();}; + req.once('aborted',abort);res.once('close',closed); + if(req.aborted || res.destroyed) abort(); + return {signal:controller.signal,dispose(){req.off('aborted',abort);res.off('close',closed);}}; +} diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index fee40d3..2f28afe 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -27,9 +27,10 @@ const OfferAssistantSession = ({ tender }) => { const live = useRef(true); const session = useRef(getToken()); const busy = useRef(false); - useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); + const lifetime = useRef(new AbortController()); + useEffect(() => { live.current = true; lifetime.current = new AbortController(); return () => { live.current = false; lifetime.current.abort(); }; }, []); const current = () => live.current && Boolean(session.current) && getToken() === session.current; - const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents}); + const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal}); const [arac, setArac] = useState('sor'); const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni const [sourceName, setSourceName] = useState(''); diff --git a/src/services/offerAssistant.js b/src/services/offerAssistant.js index d030830..c61f5bd 100644 --- a/src/services/offerAssistant.js +++ b/src/services/offerAssistant.js @@ -1,8 +1,9 @@ import { validateOfferResponse } from './offerResponse.js'; /** Bind the entire multi-step request to its starting account and mounted view. */ -export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000}) { +export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000, signal}) { const token = readToken(); const check = () => { + signal?.throwIfAborted(); if (!token || readToken() !== token || !isCurrent()) throw new Error('Oturum veya ihale değişti. İşlemi güncel ekrandan yeniden başlatın.'); }; @@ -19,7 +20,10 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = ( } check(); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const cancel = () => controller.abort(); + signal?.addEventListener('abort',cancel,{once:true}); + let timedOut=false; + const timer = setTimeout(() => {timedOut=true;controller.abort();}, timeoutMs); try { const response = await fetcher(url, {signal:controller.signal, method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(payload)}); const data = await response.json().catch(() => { throw new Error('Sunucudan okunabilir JSON yanıtı alınamadı. Yeniden deneyin.'); }); @@ -28,7 +32,8 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = ( if (!response.ok || data?.success === false) throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`); return validateOfferResponse(url, data); } catch (error) { - if (controller.signal.aborted) throw new Error('AI yanıtı zaman aşımına uğradı. Önceki çalışmanız korundu; yeniden deneyin.'); + if (timedOut) throw new Error('AI yanıtı zaman aşımına uğradı. Önceki çalışmanız korundu; yeniden deneyin.'); + signal?.throwIfAborted(); throw error; - } finally { clearTimeout(timer); } + } finally { clearTimeout(timer);signal?.removeEventListener('abort',cancel); } } diff --git a/tests/ai-cancellation.test.mjs b/tests/ai-cancellation.test.mjs new file mode 100644 index 0000000..39a92c4 --- /dev/null +++ b/tests/ai-cancellation.test.mjs @@ -0,0 +1,50 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {requestLifetime} from '../server/request-lifetime.mjs'; +import {analyzeSpec} from '../server/ai.mjs'; +import {auditOfferInParts} from '../server/offer-audit-parts.mjs'; +import {requestOfferAssistant} from '../src/services/offerAssistant.js'; +test('premature real HTTP response closure aborts work after the request body is complete', {timeout:3000},async()=>{ + let ready,aborted; + const received=new Promise(resolve=>ready=resolve), cancelled=new Promise(resolve=>aborted=resolve); + const server=http.createServer((req,res)=>{ + const lifetime=requestLifetime(req,res); + lifetime.signal.addEventListener('abort',()=>{lifetime.dispose();aborted();},{once:true}); + req.resume();req.on('end',()=>ready(lifetime)); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const client=http.request({hostname:'127.0.0.1',port:server.address().port,method:'POST'}); + client.on('error',()=>{});client.end('complete request body'); + try { + const lifetime=await received;assert.equal(lifetime.signal.aborted,false); + client.destroy();await cancelled;assert.equal(lifetime.signal.aborted,true); + } finally {client.destroy();await new Promise(resolve=>server.close(resolve));} +}); +test('caller cancellation aborts provider fetch and never triggers fallback',async()=>{ + const original=globalThis.fetch,old=process.env.GEMINI_API_KEY; + process.env.GEMINI_API_KEY='synthetic-test-only';let calls=0; + const c=new AbortController(); + try { + globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{ + calls++;signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true});c.abort(); + }); + await assert.rejects(analyzeSpec({text:'Sentetik kaynak',chain:['gemini'],signal:c.signal}),{name:'AbortError'}); + assert.equal(calls,1); + } finally {globalThis.fetch=original;if(old===undefined)delete process.env.GEMINI_API_KEY;else process.env.GEMINI_API_KEY=old;} +}); +test('audit cancellation stops remaining batches and is not returned as a partial result',async()=>{ + const c=new AbortController();let calls=0; + await assert.rejects(auditOfferInParts(['Numune'],'x'.repeat(50000),async()=>{ + calls++;c.abort();throw new DOMException('Aborted','AbortError'); + },{signal:c.signal}),{name:'AbortError'}); + assert.ok(calls<=2); +}); +test('closing assistant view aborts its pending HTTP fetch',async()=>{ + const c=new AbortController();let aborted=false; + await assert.rejects(requestOfferAssistant({url:'/api/ai/belge-sor',body:{},readToken:()=>'test',signal:c.signal, + fetcher:async(url,{signal})=>new Promise((resolve,reject)=>{ + signal.addEventListener('abort',()=>{aborted=true;reject(new DOMException('Aborted','AbortError'));},{once:true});c.abort(); + })}),{name:'AbortError'}); + assert.equal(aborted,true); +});