diff --git a/app/test/e2e/specs/chat-harness-subagent-continue.spec.ts b/app/test/e2e/specs/chat-harness-subagent-continue.spec.ts index 329082b08c..a030b077fa 100644 --- a/app/test/e2e/specs/chat-harness-subagent-continue.spec.ts +++ b/app/test/e2e/specs/chat-harness-subagent-continue.spec.ts @@ -7,17 +7,34 @@ * 3. The harness exits early, returns [SUBAGENT_AWAITING_USER] to the * orchestrator. * 4. Orchestrator surfaces the question to the user. - * 5. User replies in the composer ("the main repo"). + * 5. User replies in the composer. * 6. Orchestrator calls `continue_subagent` with the user's answer. * 7. Researcher resumes from checkpoint, produces final answer. * 8. Orchestrator produces final synthesis with canary. * + * Scripting: `llmKeywordRules` + mock `{{DYNAMIC_*}}` template substitution. + * A prior version used `llmForcedResponses` but that FIFO drains one entry + * per `/chat/completions` call regardless of who called; the researcher's + * own harness loop plus any ancillary summarisation/memory-prep call + * shifts responses out of order and the scripted final canary lands on + * the wrong turn or never renders (tinyhumansai/openhuman#4517). Keyword + * rules route each call by a substring of its latest user/tool message + * and are never consumed. + * + * The orchestrator's `continue_subagent` call needs a real `task_id` + * (`sub-` generated at delegate time in + * `src/openhuman/agent_orchestration/tools/dispatch.rs`). Tests can't + * know it ahead of time, so the mock substitutes `{{DYNAMIC_TASK_ID}}` / + * `{{DYNAMIC_AGENT_ID}}` from the latest `[SUBAGENT_AWAITING_USER]` + * envelope in the message history (see + * `scripts/mock-api/routes/llm/shared.mjs::renderDynamicPlaceholders`). + * * Verifies: * - The tool timeline shows a subagent entry. * - The final canary text renders in the DOM. * - The mock LLM received ≥4 POST requests (orchestrator initial + - * researcher initial + orchestrator relay + researcher resumed + - * orchestrator final). + * researcher initial + orchestrator relay + orchestrator continue + + * researcher resumed + orchestrator final). * - Persisted thread JSONL contains the final canary text. */ import { waitForApp } from '../helpers/app-helpers'; @@ -37,33 +54,66 @@ import { navigateViaHash } from '../helpers/shared-flows'; import { getRequestLog, setMockBehavior, startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-chat-harness-subagent-continue'; -const PROMPT = 'Research the answer and tell me a marker phrase.'; +// Per-turn probes carry disjoint distinctive tokens so `pickProbeText`'s +// substring match routes each of the six model calls to exactly one rule +// regardless of any ancillary summarisation / title-gen calls the harness may +// issue on top of the happy-path turns. +const PROMPT = + 'Please delegate a research task with the chromatophore-request badge and return a marker phrase.'; +const DELEGATE_PROMPT = 'Return the harlequin-token phrase after searching.'; +const CLARIFICATION_QUESTION = 'Which repo should I search for the citrine-cliff badge?'; +const USER_ANSWER = 'Please try the wisteria-tag project.'; +const RESEARCHER_FINAL_REPLY = 'In the wisteria-tag project I found the zenith-beacon marker.'; const CANARY_FINAL = 'subagent-continue-canary-9bc3f'; -const CLARIFICATION_QUESTION = 'Which repo should I search?'; -const USER_ANSWER = 'the main repo'; -const RESEARCHER_FINAL_REPLY = 'After searching the main repo, the answer is 42.'; - -// Five forced responses, popped in order by the mock LLM streamer: -// 1. Orchestrator: emits `research` tool call. -// 2. Researcher: calls `ask_user_clarification` (triggers early exit). -// 3. Orchestrator: sees [SUBAGENT_AWAITING_USER], asks user the question. -// 4. Orchestrator (after user reply): calls `continue_subagent` with user's answer. -// 5. Researcher (resumed): produces final text answer. -// 6. Orchestrator: final synthesis containing the canary. -const FORCED_RESPONSES = [ - // 1. Orchestrator: delegate to researcher. + +// Content-addressed keyword rules — never depleted, immune to extra +// ancillary /chat/completions calls (#4517). +// +// ORDER MATTERS: the researcher's resumed probe includes both the framing +// `[User's answer to your clarification question]` prepended by +// `src/openhuman/agent_orchestration/tools/continue_subagent.rs` AND the raw +// user answer text (`wisteria-tag`). Its rule must appear BEFORE the +// orchestrator-continue rule (keyed on `wisteria-tag`) so the researcher's +// resumed call hits the researcher rule first. +const KEYWORD_RULES = [ + // Researcher's resumed turn — the harness reconstructs history and appends + // "[User's answer to your clarification question]\n" as a user + // message (continue_subagent.rs). Key on the framing so this rule only + // matches the researcher, not the orchestrator's own continue turn (which + // sees the composer message without framing). + { keyword: "user's answer to your clarification question", content: RESEARCHER_FINAL_REPLY }, + // Orchestrator's final synthesis — probe is the tool result carrying the + // researcher's resumed output. `zenith-beacon` is unique to that reply. + { keyword: 'zenith-beacon', content: `Done. The result is: ${CANARY_FINAL}` }, + // Orchestrator's continue_subagent turn — user answered the clarification + // in the composer; the latest user message is USER_ANSWER. The mock + // substitutes `{{DYNAMIC_TASK_ID}}` / `{{DYNAMIC_AGENT_ID}}` from the + // preceding `[SUBAGENT_AWAITING_USER]` envelope in message history so the + // resulting tool_call carries the real runtime-generated task_id. { + keyword: 'wisteria-tag', content: '', toolCalls: [ { - id: 'call_research_1', - name: 'research', - arguments: JSON.stringify({ prompt: 'Tell me a marker phrase' }), + id: 'call_continue_1', + name: 'continue_subagent', + arguments: JSON.stringify({ + task_id: '{{DYNAMIC_TASK_ID}}', + agent_id: '{{DYNAMIC_AGENT_ID}}', + message: USER_ANSWER, + }), }, ], }, - // 2. Researcher: ask clarification (tool call). + // Orchestrator's relay turn — probe is the `[SUBAGENT_AWAITING_USER]` + // envelope; `citrine-cliff` is embedded in the clarification question and + // does not appear in any later probe. + { keyword: 'citrine-cliff', content: `The researcher needs to know: ${CLARIFICATION_QUESTION}` }, + // Researcher's initial turn — dispatch renders "Task:\n" + // (archetype_delegation.rs render_structured_handoff) so `harlequin-token` + // is unique to the researcher's incoming user message. { + keyword: 'harlequin-token', content: '', toolCalls: [ { @@ -73,27 +123,22 @@ const FORCED_RESPONSES = [ }, ], }, - // 3. Orchestrator: relay the question to the user. - { content: `The researcher needs to know: ${CLARIFICATION_QUESTION}` }, - // 4. Orchestrator (after user replies): continue_subagent. + // Orchestrator's initial turn — user PROMPT. Shared with the fire-and-forget + // title-gen call (threadSlice.ts, tools: None) which sees the same probe; + // `chat_with_system` consumes `content` and ignores unexpected tool_calls, + // so a delegation-triggering rule here is safe for both callers (benign + // "Delegating to researcher." title). { - content: '', + keyword: 'chromatophore-request', + content: 'Delegating to researcher.', toolCalls: [ { - id: 'call_continue_1', - name: 'continue_subagent', - arguments: JSON.stringify({ - task_id: '{{DYNAMIC_TASK_ID}}', - agent_id: 'researcher', - message: USER_ANSWER, - }), + id: 'call_research_1', + name: 'research', + arguments: JSON.stringify({ prompt: DELEGATE_PROMPT }), }, ], }, - // 5. Researcher (resumed): final answer. - { content: RESEARCHER_FINAL_REPLY }, - // 6. Orchestrator: final synthesis. - { content: `Done. The result is: ${CANARY_FINAL}` }, ]; interface RuntimeSnapshot { @@ -132,12 +177,12 @@ describe('Chat harness — orchestrator → subagent continuation flow', () => { await waitForApp(); await resetApp(USER_ID); - setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES)); + setMockBehavior('llmKeywordRules', JSON.stringify(KEYWORD_RULES)); setMockBehavior('llmStreamChunkDelayMs', '10'); }); after(async () => { - setMockBehavior('llmForcedResponses', ''); + setMockBehavior('llmKeywordRules', ''); setMockBehavior('llmStreamChunkDelayMs', ''); await stopMockServer(); }); @@ -226,9 +271,9 @@ describe('Chat harness — orchestrator → subagent continuation flow', () => { const llmHits = log.filter( r => r.method === 'POST' && r.url.includes('/openai/v1/chat/completions') ); - // Orchestrator turn 1 + researcher turn + orchestrator turn 2 (relay question) - // + orchestrator turn 3 (continue) + researcher turn 2 + orchestrator turn 4 (synthesis) - // = 6, but accept ≥4 for robustness. + // Orchestrator turn 1 + researcher turn 1 + orchestrator relay + // + orchestrator continue + researcher resumed + orchestrator final + // = 6, but accept ≥4 for robustness against harness optimisations. expect(llmHits.length).toBeGreaterThanOrEqual(4); }); diff --git a/scripts/mock-api/routes/__tests__/llm.test.mjs b/scripts/mock-api/routes/__tests__/llm.test.mjs index f6c8e4058b..01e6af3be9 100644 --- a/scripts/mock-api/routes/__tests__/llm.test.mjs +++ b/scripts/mock-api/routes/__tests__/llm.test.mjs @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { handleLlmCompletions } from "../llm.mjs"; import { handleIntegrations } from "../integrations.mjs"; +import { + applyDynamicPlaceholdersToResponse, + renderDynamicPlaceholders, +} from "../llm/shared.mjs"; import { listMockLlmThreads, resetMockBehavior, @@ -339,3 +343,195 @@ test("records thread state for multi-turn mock LLM sessions", () => { assert.equal(thread.lastFamily, "summarization"); assert.equal(thread.turnCount, 1); }); + +// ── {{DYNAMIC_*}} placeholder substitution (#4517) ────────────────── +// +// Envelope shape matches awaiting_user_envelope() in +// src/openhuman/agent_orchestration/tools/awaiting_user.rs — the parser +// must handle the exact production format (JSON-encoded question, +// `worker_thread_id: (none)`, trailing instruction block). +function subagentAwaitingUserEnvelope({ + taskId = "sub-abc123-fake-uuid", + agentId = "researcher", + workerThreadId = "(none)", + question = "Which repo should I search?", +} = {}) { + return `[SUBAGENT_AWAITING_USER] +task_id: ${taskId} +agent_id: ${agentId} +worker_thread_id: ${workerThreadId} +question: ${JSON.stringify(question)} +[/SUBAGENT_AWAITING_USER] + +The sub-agent needs clarification before it can continue. Surface the above question to the user. When the user responds, call continue_subagent with the task_id, agent_id, and the user's answer as the message parameter.`; +} + +test("renderDynamicPlaceholders substitutes task_id and agent_id from history", () => { + const parsedBody = { + messages: [ + { role: "user", content: "please research the codex marker" }, + { + role: "tool", + content: subagentAwaitingUserEnvelope({ + taskId: "sub-runtime-42", + agentId: "researcher", + }), + }, + { role: "user", content: "the main repo" }, + ], + }; + const rendered = renderDynamicPlaceholders( + '{"task_id":"{{DYNAMIC_TASK_ID}}","agent_id":"{{DYNAMIC_AGENT_ID}}","message":"the main repo"}', + parsedBody, + ); + assert.equal( + rendered, + '{"task_id":"sub-runtime-42","agent_id":"researcher","message":"the main repo"}', + ); +}); + +test("renderDynamicPlaceholders returns input unchanged when no envelope present", () => { + const parsedBody = { + messages: [{ role: "user", content: "no envelope here" }], + }; + assert.equal( + renderDynamicPlaceholders("{{DYNAMIC_TASK_ID}}", parsedBody), + "{{DYNAMIC_TASK_ID}}", + ); +}); + +test("renderDynamicPlaceholders short-circuits when text has no placeholders", () => { + // Guardrail: no envelope scan for content without placeholders. + assert.equal(renderDynamicPlaceholders("plain text", null), "plain text"); +}); + +test("applyDynamicPlaceholdersToResponse renders content and toolCalls arguments without mutating input", () => { + const parsedBody = { + messages: [ + { + role: "tool", + content: subagentAwaitingUserEnvelope({ taskId: "sub-xyz" }), + }, + ], + }; + const original = { + content: "prefix {{DYNAMIC_TASK_ID}}", + toolCalls: [ + { + id: "call_1", + name: "continue_subagent", + arguments: '{"task_id":"{{DYNAMIC_TASK_ID}}"}', + }, + ], + }; + const rendered = applyDynamicPlaceholdersToResponse(original, parsedBody); + assert.equal(rendered.content, "prefix sub-xyz"); + assert.equal(rendered.toolCalls[0].arguments, '{"task_id":"sub-xyz"}'); + // Purity: input must not be mutated (call sites re-read the rule on the + // next request; mutation would let a first substitution leak into later + // turns that don't have an envelope). + assert.equal(original.content, "prefix {{DYNAMIC_TASK_ID}}"); + assert.equal( + original.toolCalls[0].arguments, + '{"task_id":"{{DYNAMIC_TASK_ID}}"}', + ); +}); + +test("llmKeywordRules substitute {{DYNAMIC_TASK_ID}} in continue_subagent tool_call args", () => { + setMockBehaviors( + { + llmKeywordRules: JSON.stringify([ + { + keyword: "user answer", + content: "", + toolCalls: [ + { + id: "call_continue_1", + name: "continue_subagent", + arguments: JSON.stringify({ + task_id: "{{DYNAMIC_TASK_ID}}", + agent_id: "{{DYNAMIC_AGENT_ID}}", + message: "the main repo", + }), + }, + ], + }, + ]), + }, + "replace", + ); + + const ctx = makeCtx({ + parsedBody: { + model: "e2e-mock-model", + // No tools → not a "primary turn" for the forced-response FIFO, but + // keyword rules still fire and this validates the substitution site. + messages: [ + { + role: "tool", + content: subagentAwaitingUserEnvelope({ + taskId: "sub-realtime-777", + agentId: "researcher", + }), + }, + { role: "user", content: "here is my user answer" }, + ], + }, + }); + + assert.equal(handleLlmCompletions(ctx), true); + const body = JSON.parse(ctx.res.body); + const args = body.choices[0].message.tool_calls[0].function.arguments; + const parsed = JSON.parse(args); + assert.equal(parsed.task_id, "sub-realtime-777"); + assert.equal(parsed.agent_id, "researcher"); + assert.equal(parsed.message, "the main repo"); + // Placeholder text must be fully consumed. + assert.ok(!args.includes("{{DYNAMIC_"), `args should not contain unresolved placeholders: ${args}`); +}); + +test("llmKeywordRules leave the rule unmutated across successive requests", () => { + // Regression guard for applyDynamicPlaceholdersToResponse purity: two + // requests, only the first has an envelope in history. Second must still + // see the raw `{{DYNAMIC_TASK_ID}}` (which then falls through unchanged + // since there is no envelope to substitute from). + setMockBehaviors( + { + llmKeywordRules: JSON.stringify([ + { + keyword: "answer", + content: "raw {{DYNAMIC_TASK_ID}}", + }, + ]), + }, + "replace", + ); + + const first = makeCtx({ + parsedBody: { + model: "e2e-mock-model", + messages: [ + { + role: "tool", + content: subagentAwaitingUserEnvelope({ taskId: "sub-first" }), + }, + { role: "user", content: "my answer" }, + ], + }, + }); + assert.equal(handleLlmCompletions(first), true); + const firstBody = JSON.parse(first.res.body); + assert.equal(firstBody.choices[0].message.content, "raw sub-first"); + + const second = makeCtx({ + parsedBody: { + model: "e2e-mock-model", + messages: [{ role: "user", content: "another answer" }], + }, + }); + assert.equal(handleLlmCompletions(second), true); + const secondBody = JSON.parse(second.res.body); + // No envelope → helper leaves `{{DYNAMIC_TASK_ID}}` verbatim (identity + // fast-path). Prior substitution must not have poisoned the stored rule. + assert.equal(secondBody.choices[0].message.content, "raw {{DYNAMIC_TASK_ID}}"); +}); diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs index 2c69bccd04..649b002709 100644 --- a/scripts/mock-api/routes/llm.mjs +++ b/scripts/mock-api/routes/llm.mjs @@ -7,7 +7,12 @@ import { setMockBehavior, } from "../state.mjs"; import { buildDynamicCompletion } from "./llm/dynamic.mjs"; -import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs"; +import { + applyDynamicPlaceholdersToResponse, + headerValue, + pickProbeText, + resolveThreadKey, +} from "./llm/shared.mjs"; // The scripted `llmForcedResponses` FIFO models the *interactive* agent turn, // which always advertises tools (the orchestrator's delegate_* tools). Ancillary @@ -293,9 +298,10 @@ function handleStreamingCompletion({ if (Array.isArray(forced) && forced.length > 0) { const next = forced.shift(); setMockBehavior("llmForcedResponses", JSON.stringify(forced)); + const rendered = applyDynamicPlaceholdersToResponse(next, parsedBody); script = defaultStreamScript({ - content: next.content, - toolCalls: next.toolCalls, + content: rendered.content, + toolCalls: rendered.toolCalls, }); } } @@ -309,9 +315,10 @@ function handleStreamingCompletion({ for (const rule of rules) { if (!rule || typeof rule.keyword !== "string") continue; if (probe.includes(rule.keyword.toLowerCase())) { + const rendered = applyDynamicPlaceholdersToResponse(rule, parsedBody); script = defaultStreamScript({ - content: rule.content, - toolCalls: rule.toolCalls, + content: rendered.content, + toolCalls: rendered.toolCalls, }); break; } @@ -327,16 +334,20 @@ function handleStreamingCompletion({ ) { script = dynamic.streamScript; } else { + const renderedRule = applyDynamicPlaceholdersToResponse(rule, parsedBody); const fallback = - typeof rule?.content === "string" && rule.content.length > 0 - ? rule.content + typeof renderedRule?.content === "string" && + renderedRule.content.length > 0 + ? renderedRule.content : typeof mockBehavior.llmFallbackContent === "string" && mockBehavior.llmFallbackContent.length > 0 ? mockBehavior.llmFallbackContent : "Hello from e2e mock agent"; script = defaultStreamScript({ content: fallback, - toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined, + toolCalls: Array.isArray(renderedRule?.toolCalls) + ? renderedRule.toolCalls + : undefined, }); } } @@ -640,11 +651,12 @@ export function handleLlmCompletions(ctx) { Array.isArray(requestRule?.toolCalls) || typeof requestRule?.content === "string" ) { + const rendered = applyDynamicPlaceholdersToResponse(requestRule, parsedBody); if (threadKey) { recordMockLlmTurn(threadKey, { requestText, - responseText: requestRule?.content ?? "", - toolCalls: requestRule?.toolCalls ?? [], + responseText: rendered?.content ?? "", + toolCalls: rendered?.toolCalls ?? [], model, family: dynamic.family, }); @@ -654,8 +666,8 @@ export function handleLlmCompletions(ctx) { Number.isInteger(requestRule?.status) ? requestRule.status : 200, buildResponse({ model, - content: requestRule?.content ?? "", - toolCalls: requestRule?.toolCalls ?? [], + content: rendered?.content ?? "", + toolCalls: rendered?.toolCalls ?? [], }), ); return true; @@ -670,16 +682,17 @@ export function handleLlmCompletions(ctx) { const next = forced.shift(); // Persist the shrunk queue back so subsequent requests advance. setMockBehavior("llmForcedResponses", JSON.stringify(forced)); + const rendered = applyDynamicPlaceholdersToResponse(next, parsedBody); if (threadKey) { recordMockLlmTurn(threadKey, { requestText, - responseText: next.content ?? "", - toolCalls: next.toolCalls ?? [], + responseText: rendered.content ?? "", + toolCalls: rendered.toolCalls ?? [], model, family: dynamic.family, }); } - json(res, 200, buildResponse({ model, ...next })); + json(res, 200, buildResponse({ model, ...rendered })); return true; } @@ -690,11 +703,12 @@ export function handleLlmCompletions(ctx) { for (const rule of rules) { if (!rule || typeof rule.keyword !== "string") continue; if (probe.includes(rule.keyword.toLowerCase())) { + const rendered = applyDynamicPlaceholdersToResponse(rule, parsedBody); if (threadKey) { recordMockLlmTurn(threadKey, { requestText, - responseText: rule.content ?? "", - toolCalls: rule.toolCalls ?? [], + responseText: rendered.content ?? "", + toolCalls: rendered.toolCalls ?? [], model, family: dynamic.family, }); @@ -704,8 +718,8 @@ export function handleLlmCompletions(ctx) { 200, buildResponse({ model, - content: rule.content ?? "", - toolCalls: rule.toolCalls ?? [], + content: rendered.content ?? "", + toolCalls: rendered.toolCalls ?? [], }), ); return true; diff --git a/scripts/mock-api/routes/llm/shared.mjs b/scripts/mock-api/routes/llm/shared.mjs index 092e7aa52f..ce03e22619 100644 --- a/scripts/mock-api/routes/llm/shared.mjs +++ b/scripts/mock-api/routes/llm/shared.mjs @@ -97,6 +97,89 @@ export function latestRoleMessage(parsedBody, role) { return matches[matches.length - 1] || null; } +// Substitute `{{DYNAMIC_*}}` placeholders using values pulled from the latest +// `[SUBAGENT_AWAITING_USER]` envelope in the message history. When the +// orchestrator receives a paused sub-agent's envelope from +// `awaiting_user_envelope()` (src/openhuman/agent_orchestration/tools/ +// awaiting_user.rs) it carries a runtime-generated +// `task_id: sub-` / `agent_id: ` / `worker_thread_id: ...`. A test +// scripting the orchestrator's follow-up `continue_subagent` tool_call can't +// know those values ahead of time — this helper substitutes them from the +// envelope so keyword-rule fixtures can drive resume flows deterministically +// (tinyhumansai/openhuman#4517). +// +// Returns `text` unchanged when it has no placeholders, no envelope is in +// history, or `parsedBody` is missing. +export function renderDynamicPlaceholders(text, parsedBody) { + if (typeof text !== "string" || text.length === 0) return text; + if (!text.includes("{{DYNAMIC_")) return text; + if (!parsedBody || !Array.isArray(parsedBody.messages)) return text; + + const fields = extractLatestAwaitingUserEnvelope(parsedBody); + if (!fields) return text; + + return text + .replace(/\{\{DYNAMIC_TASK_ID\}\}/g, fields.task_id ?? "{{DYNAMIC_TASK_ID}}") + .replace( + /\{\{DYNAMIC_AGENT_ID\}\}/g, + fields.agent_id ?? "{{DYNAMIC_AGENT_ID}}", + ) + .replace( + /\{\{DYNAMIC_WORKER_THREAD_ID\}\}/g, + fields.worker_thread_id ?? "{{DYNAMIC_WORKER_THREAD_ID}}", + ); +} + +// Walk messages newest-first, find the last `[SUBAGENT_AWAITING_USER]… +// [/SUBAGENT_AWAITING_USER]` block, and parse its `key: value` lines. Returns +// null if not found. +function extractLatestAwaitingUserEnvelope(parsedBody) { + for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) { + const m = parsedBody.messages[i]; + if (!m || typeof m !== "object") continue; + const content = normalizeMessageContent(m.content); + const openIdx = content.indexOf("[SUBAGENT_AWAITING_USER]"); + if (openIdx < 0) continue; + const closeIdx = content.indexOf("[/SUBAGENT_AWAITING_USER]", openIdx); + const body = + closeIdx > openIdx + ? content.slice(openIdx, closeIdx) + : content.slice(openIdx); + const fields = {}; + for (const line of body.split(/\r?\n/)) { + const match = /^\s*([a-zA-Z_]+):\s*(.*)$/.exec(line); + if (!match) continue; + const [, key, val] = match; + if (!(key in fields)) fields[key] = val.trim(); + } + return fields; + } + return null; +} + +// Apply `renderDynamicPlaceholders` to a rule / forced-response's `content` and +// each `toolCalls[*].arguments` (when they're stringified JSON, which the mock +// forwards verbatim to the agent harness). No-op for entries that don't carry +// placeholders. Returns a shallow copy — never mutates the input. +export function applyDynamicPlaceholdersToResponse(response, parsedBody) { + if (!response || typeof response !== "object") return response; + const rendered = { ...response }; + if (typeof rendered.content === "string") { + rendered.content = renderDynamicPlaceholders(rendered.content, parsedBody); + } + if (Array.isArray(rendered.toolCalls)) { + rendered.toolCalls = rendered.toolCalls.map((tc) => { + if (!tc || typeof tc !== "object") return tc; + const next = { ...tc }; + if (typeof next.arguments === "string") { + next.arguments = renderDynamicPlaceholders(next.arguments, parsedBody); + } + return next; + }); + } + return rendered; +} + export function resolveThreadKey(ctx) { const { parsedBody, req } = ctx; const headers = req?.headers || {};