/** * recipe payload 與回應處理層 —— CP `arcrun-usable` 步驟 5 缺口② * SDD: workflow-discovery task 3.12 * * 為什麼要有這三層(別刪): * 舊 schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺) * ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code; * 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式,換源必壞。 * leo:三層模型=①零件 ②auth recipe ③payload recipe,第③層過去不存在。 * * 本檔測純函式層(body_template 插值 / response_map 正規化), * 不打真外部 API——外部呼叫由 stage 端到端驗(features/09)。 */ import { describe, it, expect } from 'vitest'; import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload'; describe('body_template:payload 收回 recipe(第③層)', () => { it('巢狀結構的 {{var}} 都會被替換(不只 top-level)', () => { const out = renderBodyTemplate( { contents: [{ parts: [{ text: '{{prompt}}' }] }] }, { prompt: '你好' }, ); expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] }); }); it('單一引用保留原型別(陣列/物件不被 stringify)', () => { const out = renderBodyTemplate( { messages: '{{history}}', n: '{{count}}' }, { history: [{ role: 'user' }], count: 3 }, ) as Record; expect(out.messages).toEqual([{ role: 'user' }]); expect(out.n).toBe(3); }); it('混合文字仍拼成字串', () => { const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record; expect(out.q).toBe('請回答:天氣'); }); it('支援 dot path 取值', () => { const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, { assemble: { data: { prompt: '深層值' } }, }) as Record; expect(out.t).toBe('深層值'); }); it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => { const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record; expect(out.t).toBe('{{nope}}'); }); it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => { expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined(); }); }); describe('response_map:回應正規化(換源不必改 workflow)', () => { const geminiBody = { candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }], }; it('path 取值:Gemini 形狀 → 純文字', () => { const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' }); expect(out.text).toBe('【答】台北是首都'); }); it('換源=換 recipe:Claude 形狀用不同 path,同樣取得出文字', () => { const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] }; const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' }); expect(out.text).toBe('Claude 的答案'); }); it('Workers AI 形狀(binding 回傳)同樣走 path', () => { const waiBody = { response: 'Workers AI 的答案' }; const out = applyResponseMap(waiBody, { text_path: 'response' }); expect(out.text).toBe('Workers AI 的答案'); }); it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => { const gemma = { candidates: [{ content: { parts: [ { text: '讓我想想…', thought: true }, { text: '真正的答案' }, ], }, }], }; const out = applyResponseMap(gemma, { text_path: 'candidates.0.content.parts', thinking_model: true, }); expect(out.text).toBe('真正的答案'); }); it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => { const out = applyResponseMap( { r: 'Draft: 【答】正確內容' }, { text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' }, ); expect(out.text).toBe('正確內容'); }); it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => { const out = applyResponseMap( { r: 'Answer: * 【答】內容' }, { text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' }, ); expect(out.text).toBe('內容'); }); it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => { const out = applyResponseMap(geminiBody, undefined); expect(out.text).toBeUndefined(); expect(out.raw).toEqual(geminiBody); }); it('path 取不到 → 誠實回 undefined,不編造', () => { const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' }); expect(out.text).toBeUndefined(); }); });