@data-fair/lib-agents-sim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @data-fair/lib-agents-sim
2
+
3
+ Primitives for judged browser simulations of an [agents](https://github.com/data-fair/agents)
4
+ chat: drive a real conversation with a simulated user in a real browser, capture
5
+ what happened, and hand the transcript to a judge. It does not own cases, login,
6
+ settings seeding, or the turn loop — those stay in the host repo, which knows
7
+ its own routes, fixtures and account setup. It also ships a small Claude Code
8
+ bridge (a dev tool, not a simulation primitive) that exposes the Claude Agent
9
+ SDK as an OpenAI-compatible provider, so a dev workspace can drive the assistant
10
+ under test on a Claude subscription instead of a metered API key.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i -D @data-fair/lib-agents-sim
16
+ ```
17
+
18
+ ## Which half needs which peer
19
+
20
+ Every peer is **optional**, because the package has two independent halves and
21
+ few consumers want both. Install only what the half you use needs.
22
+
23
+ | You want | Install |
24
+ | --- | --- |
25
+ | The **harness** primitives (`createChatDriver`, `captureGateway`, `selectCases`, `reportCases`, `writeEvidence`, …) | `@playwright/test` |
26
+ | The **bridge** (`df-agents-bridge`) | `@anthropic-ai/claude-agent-sdk` and `@modelcontextprotocol/sdk` |
27
+
28
+ ```bash
29
+ # harness only
30
+ npm i -D @playwright/test
31
+ # bridge only
32
+ npm i -D @anthropic-ai/claude-agent-sdk @modelcontextprotocol/sdk
33
+ ```
34
+
35
+ The harness primitives are built against Playwright's `Page` / `FrameLocator`
36
+ types and run inside your own Playwright project, so bring your own version.
37
+ The bridge needs nothing from Playwright, which is why a bridge-only consumer
38
+ is not forced into a browser install.
39
+
40
+ Run `df-agents-bridge` without the two SDKs and it exits with an actionable
41
+ message instead of a raw `ERR_MODULE_NOT_FOUND`, naming the install command
42
+ above.
43
+
44
+ **zod warning.** If your tree also contains the `ai` package, installing the
45
+ Agent SDK may hoist zod 4 and break `ai`'s type inference. Add
46
+ `"overrides": { "@anthropic-ai/claude-agent-sdk": { "zod": "3.25.76" } }`.
47
+
48
+ ## `df-agents-sim-init`
49
+
50
+ ```bash
51
+ npx df-agents-sim-init [--force]
52
+ ```
53
+
54
+ Copies the `/simulate` skill and the `simulation-judge` sub-agent definition
55
+ into your repo's `.claude/skills/simulate/SKILL.md` and
56
+ `.claude/agents/simulation-judge.md`. These cannot be loaded from
57
+ `node_modules` — Claude Code reads them from the repository — so they are
58
+ copied, not referenced, and **can drift** from the version in this package.
59
+ The command prints the package version it copied from so drift is at least
60
+ detectable; without `--force` it skips a file that already exists rather than
61
+ overwriting local edits.
62
+
63
+ ## Minimal runner example
64
+
65
+ A host repo owns the Playwright test that drives one case end to end. For a
66
+ chat embedded in an iframe (the common case for a host application), pass
67
+ `page.frameLocator('iframe')` to `createChatDriver`; for a page where the chat
68
+ *is* the page, pass `page` itself — the driver's selectors are identical
69
+ either way.
70
+
71
+ ```ts
72
+ import { test } from '@playwright/test'
73
+ import {
74
+ createChatDriver, captureGateway, nextUserMessage, isDone,
75
+ writeEvidence, selectCases, type Transcript, type SimulationCase
76
+ } from '@data-fair/lib-agents-sim'
77
+
78
+ const cases: SimulationCase[] = [
79
+ { name: 'find-a-dataset', route: '/embed/chat', persona: 'A curious analyst.', goal: 'Find last quarter\'s sales dataset.', maxTurns: 6 }
80
+ ]
81
+
82
+ for (const simCase of selectCases(cases, [])) {
83
+ test(`simulation: ${simCase.name}`, async ({ page }) => {
84
+ const gateway = captureGateway(page)
85
+ await page.goto(simCase.route)
86
+
87
+ const chat = createChatDriver(page.frameLocator('iframe'))
88
+ const conversation: Array<{ role: string, text: string }> = []
89
+ let error: string | undefined
90
+
91
+ try {
92
+ for (let i = 0; i < simCase.maxTurns; i++) {
93
+ const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i)
94
+ if (isDone(message)) break
95
+ await chat.sendMessage(message)
96
+ await chat.waitForTurn()
97
+ // Read into a local FIRST, then replace: clearing up front means a throw
98
+ // from readConversation leaves the transcript empty, losing every prior
99
+ // turn — and an empty transcript is the one thing a judge cannot judge.
100
+ const read = await chat.readConversation()
101
+ conversation.length = 0
102
+ conversation.push(...read)
103
+ }
104
+ } catch (err) {
105
+ error = err instanceof Error ? err.message : String(err)
106
+ }
107
+
108
+ const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [] }
109
+ // `valid` is derived, never hardcoded: the sidecar exists to tell a run that
110
+ // really happened apart from one that fell over, so that `reportCases` says
111
+ // "invalid (…)" instead of re-reporting the previous run's verdict.
112
+ writeEvidence(simCase.name, transcript, {
113
+ case: simCase.name, valid: !error, error,
114
+ assistantModel: 'sonnet', userModel: 'haiku',
115
+ turns: conversation.length / 2, durationMs: 0, finishedAt: new Date().toISOString()
116
+ })
117
+ if (error) throw new Error(`run invalid: ${error}`)
118
+ })
119
+ }
120
+ ```
121
+
122
+ Then judge each written transcript with the `simulation-judge` sub-agent (via
123
+ the copied `/simulate` skill), and turn the evidence directory into a pass/fail
124
+ summary with `reportCases(cases, evidenceDir)` — the host repo's own report
125
+ script decides where cases live and what to do with the failure count it
126
+ returns.
127
+
128
+ ### Where the evidence goes
129
+
130
+ `writeEvidence(name, transcript, sidecar, dir?)` writes `sim-<name>.json` (the
131
+ transcript the judge reads) and `sim-<name>.run.json` (the validity sidecar).
132
+ `dir` defaults to the exported `evidenceDir`, which is
133
+ `path.join(process.cwd(), 'simulations', 'tmp')` — resolved against the host
134
+ repo's working directory, so the default only makes sense if you run your suite
135
+ from the repository root. Pass `dir` explicitly to put evidence anywhere else,
136
+ and hand the same directory to `reportCases(cases, dir)` so the reader and the
137
+ writer agree.
138
+
139
+ ## Scripts the copied `/simulate` skill expects
140
+
141
+ `df-agents-sim-init` copies the skill **verbatim**, and the skill refers to npm
142
+ scripts by the names the origin repository uses. It cannot know yours, so define
143
+ these three in your `package.json` (adjust the paths to your layout):
144
+
145
+ ```json
146
+ {
147
+ "scripts": {
148
+ "dev-bridge": "df-agents-bridge",
149
+ "simulate": "playwright test -c playwright.sim.config.ts --project=simulate",
150
+ "simulate:report": "node simulations/report.ts"
151
+ }
152
+ }
153
+ ```
154
+
155
+ - `dev-bridge` — starts the bridge the simulated user and the assistant both
156
+ talk to. Must be running before `simulate`.
157
+ - `simulate` — runs your Playwright project containing the scenario specs. Keep
158
+ it out of your default `test` script: a bare `npm test` would otherwise spend
159
+ plan quota.
160
+ - `simulate:report` — calls `reportCases(cases, evidenceDir)` and exits non-zero
161
+ on the failure count it returns.
162
+
163
+ If you prefer different names, edit the copied
164
+ `.claude/skills/simulate/SKILL.md` to match — but remember that a later
165
+ `df-agents-sim-init --force` overwrites it.
package/bin/init.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/bin/init.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copies the judge definition and the /simulate skill into the consuming repo's
4
+ * .claude/ directory. They cannot be loaded from node_modules — Claude Code reads
5
+ * them from the repository — so they are copied and can drift. The version is
6
+ * printed so drift is at least detectable.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const templates = path.join(here, '..', 'templates');
13
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
14
+ const cwd = process.cwd();
15
+ const targets = [
16
+ { from: 'simulation-judge.md', to: path.join(cwd, '.claude', 'agents', 'simulation-judge.md') },
17
+ { from: 'simulate-skill.md', to: path.join(cwd, '.claude', 'skills', 'simulate', 'SKILL.md') }
18
+ ];
19
+ for (const { from, to } of targets) {
20
+ fs.mkdirSync(path.dirname(to), { recursive: true });
21
+ if (fs.existsSync(to) && !process.argv.includes('--force')) {
22
+ console.log(`skipped (exists, use --force): ${path.relative(cwd, to)}`);
23
+ continue;
24
+ }
25
+ fs.copyFileSync(path.join(templates, from), to);
26
+ console.log(`wrote ${path.relative(cwd, to)}`);
27
+ }
28
+ console.log(`from @data-fair/lib-agents-sim@${pkg.version}`);
@@ -0,0 +1,68 @@
1
+ import { type OpenAIToolCall } from './openai.ts';
2
+ export declare const ABORTED_TOOL_RESULT = "ERROR: the conversation was aborted before this tool call could be answered. No result is available.";
3
+ export type TurnOutcome = {
4
+ type: 'tools';
5
+ calls: OpenAIToolCall[];
6
+ } | {
7
+ type: 'done';
8
+ usage?: object;
9
+ } | {
10
+ type: 'error';
11
+ message: string;
12
+ };
13
+ export type SdkMessage = {
14
+ type: string;
15
+ subtype?: string;
16
+ message?: {
17
+ content?: Array<{
18
+ type: string;
19
+ text?: string;
20
+ }>;
21
+ };
22
+ usage?: {
23
+ input_tokens?: number;
24
+ output_tokens?: number;
25
+ };
26
+ result?: unknown;
27
+ };
28
+ export declare class Conversation {
29
+ #private;
30
+ key: string;
31
+ lastSeen: number;
32
+ pending: Map<string, (result: string) => void>;
33
+ /**
34
+ * The tool names the live MCP tool server was built with. A continuation
35
+ * request declaring a different set cannot be served by this query — the model
36
+ * would be offered the stale set — so the server compares before adopting it.
37
+ */
38
+ toolNames: string[];
39
+ constructor(key: string, toolNames?: string[]);
40
+ /** Handed to the SDK as `options.abortController`, so abort() really cancels the query. */
41
+ get controller(): AbortController;
42
+ get signal(): AbortSignal;
43
+ /**
44
+ * True once the upstream query has ended (done, error or abort). A dead
45
+ * conversation must never be adopted as a continuation: nothing would ever
46
+ * resolve the turn it was handed.
47
+ */
48
+ get isDead(): boolean;
49
+ abort(): void;
50
+ /**
51
+ * Called by the MCP tool server. Records the call and suspends: the promise is
52
+ * resolved by deliverToolResults when the client's next request arrives.
53
+ */
54
+ handleToolCall(name: string, args: Record<string, unknown>): Promise<string>;
55
+ deliverToolResults(results: Array<{
56
+ id: string;
57
+ content: string;
58
+ }>): void;
59
+ /** True when every tool call handed back has been answered by this request. */
60
+ awaits(ids: string[]): boolean;
61
+ /**
62
+ * Attach an HTTP response as the sink and wait for this turn to end.
63
+ * Start the consumer loop on first use.
64
+ */
65
+ beginTurn(sink: (text: string) => void, iterator?: AsyncIterable<SdkMessage>): Promise<TurnOutcome>;
66
+ /** Mark the calls just handed back, so the next turn only reports new ones. */
67
+ handedBack(count: number): void;
68
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * One live SDK query, spanning many HTTP requests.
3
+ *
4
+ * The tricky part, and why this is not driven straight off the iterator: when the
5
+ * model calls a tool, the SDK yields the assistant message and THEN invokes the
6
+ * MCP handler. A consumer that checked for tool calls inside its `for await` body
7
+ * would look before the handler had run, loop, and block forever on an iterator
8
+ * the SDK is no longer feeding — the chat would hang on its first tool call.
9
+ *
10
+ * So the consumer loop runs for the whole conversation, writing into whichever
11
+ * HTTP response is currently attached, and a turn ends on whichever comes first:
12
+ * the handlers suspending (tool calls to hand back) or the query finishing.
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import { mcpNameToTool, mapUsage } from "./openai.js";
16
+ // Parallel tool calls in one assistant message arrive as separate handler
17
+ // invocations a tick apart. Wait this long after the first before closing the
18
+ // turn, so they are handed back together as OpenAI expects.
19
+ const SETTLE_MS = 50;
20
+ // What a suspended tool handler returns when the conversation is aborted. It
21
+ // MUST read as a failure: the model receives it as the tool's result, and a
22
+ // bare word like "aborted" would be taken for a successful answer.
23
+ export const ABORTED_TOOL_RESULT = 'ERROR: the conversation was aborted before this tool call could be answered. No result is available.';
24
+ export class Conversation {
25
+ key;
26
+ lastSeen = Date.now();
27
+ pending = new Map();
28
+ /**
29
+ * The tool names the live MCP tool server was built with. A continuation
30
+ * request declaring a different set cannot be served by this query — the model
31
+ * would be offered the stale set — so the server compares before adopting it.
32
+ */
33
+ toolNames;
34
+ #collected = [];
35
+ #handedBack = 0;
36
+ #sink = null;
37
+ #resolveTurn = null;
38
+ #settle = null;
39
+ #controller = new AbortController();
40
+ #consuming = false;
41
+ #terminal = null;
42
+ constructor(key, toolNames = []) {
43
+ this.key = key;
44
+ this.toolNames = toolNames;
45
+ }
46
+ /** Handed to the SDK as `options.abortController`, so abort() really cancels the query. */
47
+ get controller() { return this.#controller; }
48
+ get signal() { return this.#controller.signal; }
49
+ /**
50
+ * True once the upstream query has ended (done, error or abort). A dead
51
+ * conversation must never be adopted as a continuation: nothing would ever
52
+ * resolve the turn it was handed.
53
+ */
54
+ get isDead() { return this.#terminal !== null; }
55
+ abort() {
56
+ this.#terminal ??= { type: 'error', message: 'conversation aborted' };
57
+ this.#controller.abort();
58
+ // Unblock anything still waiting on a tool result.
59
+ for (const resolve of this.pending.values())
60
+ resolve(ABORTED_TOOL_RESULT);
61
+ this.pending.clear();
62
+ }
63
+ /**
64
+ * Called by the MCP tool server. Records the call and suspends: the promise is
65
+ * resolved by deliverToolResults when the client's next request arrives.
66
+ */
67
+ handleToolCall(name, args) {
68
+ const id = `call_${crypto.randomUUID()}`;
69
+ this.#collected.push({
70
+ id,
71
+ type: 'function',
72
+ function: { name: mcpNameToTool(name), arguments: JSON.stringify(args) }
73
+ });
74
+ if (this.#settle)
75
+ clearTimeout(this.#settle);
76
+ this.#settle = setTimeout(() => { this.#endTurn({ type: 'tools', calls: this.#collected.slice(this.#handedBack) }); }, SETTLE_MS);
77
+ if (this.#settle.unref)
78
+ this.#settle.unref();
79
+ return new Promise(resolve => { this.pending.set(id, resolve); });
80
+ }
81
+ deliverToolResults(results) {
82
+ for (const r of results) {
83
+ const resolve = this.pending.get(r.id);
84
+ if (!resolve)
85
+ continue;
86
+ this.pending.delete(r.id);
87
+ resolve(r.content);
88
+ }
89
+ }
90
+ /** True when every tool call handed back has been answered by this request. */
91
+ awaits(ids) {
92
+ if (this.#terminal)
93
+ return false;
94
+ return ids.length > 0 && ids.every(id => this.pending.has(id));
95
+ }
96
+ /**
97
+ * Attach an HTTP response as the sink and wait for this turn to end.
98
+ * Start the consumer loop on first use.
99
+ */
100
+ beginTurn(sink, iterator) {
101
+ if (this.#terminal) {
102
+ // The query ended between turns (rate limit, crash) with no response
103
+ // attached, so its outcome was recorded rather than dropped. Replay it:
104
+ // without this the request would await a promise nothing can settle.
105
+ return Promise.resolve(this.#terminal);
106
+ }
107
+ this.#sink = sink;
108
+ const turn = new Promise(resolve => { this.#resolveTurn = resolve; });
109
+ if (iterator && !this.#consuming) {
110
+ this.#consuming = true;
111
+ // #consume reports every failure through #endTurn, so nothing escapes here.
112
+ this.#consume(iterator).catch(() => { });
113
+ }
114
+ // A tool call can land after the previous turn's settle window closed: #endTurn
115
+ // was then a no-op and no timer stays armed, so the call would sit here forever
116
+ // while the SDK waits for a tool_result. Hand any such stragglers back now.
117
+ // A still-armed timer is left alone: it is about to close the turn properly,
118
+ // with the parallel siblings it is waiting for.
119
+ if (!this.#settle && this.#collected.length > this.#handedBack) {
120
+ this.#endTurn({ type: 'tools', calls: this.#collected.slice(this.#handedBack) });
121
+ }
122
+ return turn;
123
+ }
124
+ /** Mark the calls just handed back, so the next turn only reports new ones. */
125
+ handedBack(count) {
126
+ this.#handedBack += count;
127
+ }
128
+ async #consume(iterator) {
129
+ let usage;
130
+ try {
131
+ for await (const msg of iterator) {
132
+ if (msg.type === 'assistant') {
133
+ for (const block of msg.message?.content ?? []) {
134
+ if (block.type === 'text' && block.text)
135
+ this.#sink?.(block.text);
136
+ }
137
+ }
138
+ if (msg.type === 'result') {
139
+ usage = mapUsage(msg.usage);
140
+ if (msg.subtype !== 'success') {
141
+ this.#endTurn({ type: 'error', message: String(msg.result ?? msg.subtype) });
142
+ return;
143
+ }
144
+ }
145
+ }
146
+ this.#endTurn({ type: 'done', usage });
147
+ }
148
+ catch (err) {
149
+ this.#endTurn({ type: 'error', message: err instanceof Error ? err.message : String(err) });
150
+ }
151
+ }
152
+ #endTurn(outcome) {
153
+ if (this.#settle) {
154
+ clearTimeout(this.#settle);
155
+ this.#settle = null;
156
+ }
157
+ const resolve = this.#resolveTurn;
158
+ this.#resolveTurn = null;
159
+ this.#sink = null;
160
+ // Anything but a hand-back means the query is over. Remember it: between two
161
+ // HTTP requests there is no resolver, and dropping the outcome here is what
162
+ // left the next request awaiting a promise that could never settle.
163
+ if (outcome.type !== 'tools')
164
+ this.#terminal ??= outcome;
165
+ resolve?.(outcome);
166
+ }
167
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { MISSING_SDK_MESSAGE, isMissingSdkError } from "../missing-sdk.js";
3
+ const port = Number(process.env.BRIDGE_PORT ?? 3194);
4
+ try {
5
+ const { createServer } = await import("./server.js");
6
+ createServer({ port });
7
+ console.log(`claude-bridge listening on http://localhost:${port}`);
8
+ console.log('configure an "OpenAI Compatible" provider with this base URL + /v1 and Compatibility Mode = compatible');
9
+ }
10
+ catch (err) {
11
+ if (isMissingSdkError(err)) {
12
+ console.error(MISSING_SDK_MESSAGE);
13
+ process.exitCode = 1;
14
+ }
15
+ else {
16
+ throw err;
17
+ }
18
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pure translation between the OpenAI chat-completions wire format the agents
3
+ * gateway speaks and what the Claude Agent SDK accepts. No I/O, no SDK import —
4
+ * everything here is unit-tested.
5
+ */
6
+ export type OpenAIToolCall = {
7
+ id: string;
8
+ type: 'function';
9
+ function: {
10
+ name: string;
11
+ arguments: string;
12
+ };
13
+ };
14
+ export type OpenAIMessage = {
15
+ role: 'system' | 'user' | 'assistant' | 'tool';
16
+ content?: string | null;
17
+ tool_calls?: OpenAIToolCall[];
18
+ tool_call_id?: string;
19
+ };
20
+ export type OpenAIToolDef = {
21
+ type: 'function';
22
+ function: {
23
+ name: string;
24
+ description?: string;
25
+ parameters?: Record<string, unknown>;
26
+ };
27
+ };
28
+ export declare const MCP_SERVER_NAME = "bridge";
29
+ export declare function toolNameToMcp(name: string): string;
30
+ export declare function mcpNameToTool(name: string): string;
31
+ export declare function extractSystemPrompt(messages: OpenAIMessage[]): string;
32
+ /**
33
+ * Render the conversation as a single prompt, for the replay path (a fresh
34
+ * session). The continuation path never comes through here — it delivers tool
35
+ * results into a query that is already holding the history.
36
+ */
37
+ export declare function renderTranscript(messages: OpenAIMessage[]): string;
38
+ export declare function textChunk(id: string, model: string, text: string): {
39
+ choices: {
40
+ index: number;
41
+ delta: {
42
+ content: string;
43
+ };
44
+ finish_reason: null;
45
+ }[];
46
+ id: string;
47
+ object: string;
48
+ created: number;
49
+ model: string;
50
+ };
51
+ export declare function toolCallsChunk(id: string, model: string, calls: OpenAIToolCall[]): {
52
+ choices: {
53
+ index: number;
54
+ delta: {
55
+ tool_calls: {
56
+ index: number;
57
+ id: string;
58
+ type: string;
59
+ function: {
60
+ name: string;
61
+ arguments: string;
62
+ };
63
+ }[];
64
+ };
65
+ finish_reason: null;
66
+ }[];
67
+ id: string;
68
+ object: string;
69
+ created: number;
70
+ model: string;
71
+ };
72
+ export declare function finalChunk(id: string, model: string, finishReason: 'stop' | 'tool_calls', usage?: object): {
73
+ usage?: object | undefined;
74
+ choices: {
75
+ index: number;
76
+ delta: {};
77
+ finish_reason: "stop" | "tool_calls";
78
+ }[];
79
+ id: string;
80
+ object: string;
81
+ created: number;
82
+ model: string;
83
+ };
84
+ /**
85
+ * The SDK also reports total_cost_usd, deliberately not forwarded: it is list-price
86
+ * bookkeeping, while consumption is actually against the plan's rate limits. Showing
87
+ * it in the usage UI would invite reading it as real spend.
88
+ */
89
+ export declare function mapUsage(usage: {
90
+ input_tokens?: number;
91
+ output_tokens?: number;
92
+ } | undefined): {
93
+ prompt_tokens: number;
94
+ completion_tokens: number;
95
+ total_tokens: number;
96
+ } | undefined;
97
+ export declare function errorBody(message: string, type?: string): {
98
+ error: {
99
+ message: string;
100
+ type: string;
101
+ };
102
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pure translation between the OpenAI chat-completions wire format the agents
3
+ * gateway speaks and what the Claude Agent SDK accepts. No I/O, no SDK import —
4
+ * everything here is unit-tested.
5
+ */
6
+ // Tools reach the model namespaced by their MCP server, so every name makes a
7
+ // round trip through this prefix and must be mapped back before it is emitted.
8
+ export const MCP_SERVER_NAME = 'bridge';
9
+ const PREFIX = `mcp__${MCP_SERVER_NAME}__`;
10
+ export function toolNameToMcp(name) {
11
+ return PREFIX + name;
12
+ }
13
+ export function mcpNameToTool(name) {
14
+ return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : name;
15
+ }
16
+ export function extractSystemPrompt(messages) {
17
+ return messages
18
+ .filter(m => m.role === 'system')
19
+ .map(m => m.content ?? '')
20
+ .join('\n\n');
21
+ }
22
+ /**
23
+ * Render the conversation as a single prompt, for the replay path (a fresh
24
+ * session). The continuation path never comes through here — it delivers tool
25
+ * results into a query that is already holding the history.
26
+ */
27
+ export function renderTranscript(messages) {
28
+ const lines = ['<conversation_history>'];
29
+ for (const m of messages) {
30
+ if (m.role === 'system')
31
+ continue;
32
+ if (m.role === 'tool') {
33
+ lines.push(`tool result (id=${m.tool_call_id}): ${m.content ?? ''}`);
34
+ continue;
35
+ }
36
+ if (m.role === 'assistant' && m.tool_calls?.length) {
37
+ for (const c of m.tool_calls) {
38
+ lines.push(`assistant called tool (id=${c.id}) ${c.function.name} with ${c.function.arguments}`);
39
+ }
40
+ if (m.content)
41
+ lines.push(`assistant: ${m.content}`);
42
+ continue;
43
+ }
44
+ lines.push(`${m.role}: ${m.content ?? ''}`);
45
+ }
46
+ lines.push('</conversation_history>');
47
+ lines.push('');
48
+ lines.push('Continue this conversation: produce the next assistant turn.');
49
+ return lines.join('\n');
50
+ }
51
+ const base = (id, model) => ({
52
+ id,
53
+ object: 'chat.completion.chunk',
54
+ created: Math.floor(Date.now() / 1000),
55
+ model
56
+ });
57
+ export function textChunk(id, model, text) {
58
+ return { ...base(id, model), choices: [{ index: 0, delta: { content: text }, finish_reason: null }] };
59
+ }
60
+ export function toolCallsChunk(id, model, calls) {
61
+ return {
62
+ ...base(id, model),
63
+ choices: [{
64
+ index: 0,
65
+ delta: { tool_calls: calls.map((c, index) => ({ index, id: c.id, type: 'function', function: c.function })) },
66
+ finish_reason: null
67
+ }]
68
+ };
69
+ }
70
+ export function finalChunk(id, model, finishReason, usage) {
71
+ return {
72
+ ...base(id, model),
73
+ choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
74
+ ...(usage ? { usage } : {})
75
+ };
76
+ }
77
+ /**
78
+ * The SDK also reports total_cost_usd, deliberately not forwarded: it is list-price
79
+ * bookkeeping, while consumption is actually against the plan's rate limits. Showing
80
+ * it in the usage UI would invite reading it as real spend.
81
+ */
82
+ export function mapUsage(usage) {
83
+ if (!usage)
84
+ return undefined;
85
+ const prompt = usage.input_tokens ?? 0;
86
+ const completion = usage.output_tokens ?? 0;
87
+ return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
88
+ }
89
+ export function errorBody(message, type = 'api_error') {
90
+ return { error: { message, type } };
91
+ }