Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 64 additions & 15 deletions packages/mocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,68 @@ See `src/ollama/cl-6448-demo.test.ts` for the full demonstration: the same
assertions failing against CL-6448's broken request shape, and passing
once tools and history are actually sent.

### The adversarial output catalogue (CL-6478's shape)

Real local models misbehave in specific, repeatable ways. Every scenario
below reproduces one we actually hit — not a hypothetical edge case —
selectable in one line straight off `ollama.reply`, exactly like `.text`
and `.toolCall`:

```ts
ollama.onChat(() => ollama.reply.malformedToolName());
```

| Scenario | What it reproduces |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `.malformedToolName()` | CL-6478's flagship case: `qwen3.8:27b` leaked `\n</parameter` into a tool-call function name. |
| `.toolNameOfLength(n)` | A tool name of exactly `n` characters — pass 63, 64, 65 to prove the boundary three shipping tools sit at. |
| `.textlessToolCall(name, args?)` | An `inference.done` with no text at all — a tool-only round, not an empty reply. |
| `.wrongShapedToolArgs(name, args)` | Valid JSON tool-call arguments that don't match the declared schema. |
| `.truncatedToolArgs(name, rawArgs)` | Arguments that are NOT valid JSON — a mid-stream cutoff, reaching the wire byte-for-byte. |
| `.refusal(text?)` | The model declines to answer. |
| `.oversized(approxChars?)` | A large enough text blob to exercise a truncation or blob-spill path. |
| `.hallucinatedToolName()` | A plausible-but-nonexistent tool name (`skills_load` instead of the real `load_skill`). |

`sequence([...])` scripts one reply per turn — turn 1 malformed, turn 2
normal — so CL-6478's "does the room survive?" contract is directly
testable:

```ts
import { sequence } from "@corbits/mocks/ollama";

ollama.onChat(
sequence([
ollama.reply.malformedToolName(),
ollama.reply.text("turn 2 — does the room survive?"),
]),
);
```

CL-6478's real fix — `sanitizeToolNameForPersistence` in
`vendor/intx/hub-sessions/src/sanitize-tool-name.ts` — collapses a name
`@intx/inference`'s `encodeToolName` cannot round-trip to a stable
placeholder before it is ever persisted, so one bad tool-call name can
never wedge a room forever. `src/ollama/cl-6478-demo.test.ts` demonstrates
that regression-guard shape at this mock layer: a turn assembler that
persists the malformed name unchanged carries it straight into the next
turn's history. Wiring an equivalent guard through the real hub-sessions
turn assembler is a follow-up — this package proves the shape, it does not
(yet) replace that test.

## API shape

| Export | What it's for |
| ---------------------------------------------- | ---------------------------------------------------------------------------- |
| `createOllamaMock(options?)` | Builds one mock instance. `options.models` seeds the `/api/tags` catalogue. |
| `mock.fetch` | A `(Request) => Promise<Response>` handler — the in-process path. |
| `mock.listen(port?)` | Starts a real Bun HTTP server; returns `{ url, close() }` — the server path. |
| `mock.setModels(models)` | Rewrites the catalogue mid-test (a connect flow that re-reads it). |
| `mock.onChat(handler)` | Scripts every subsequent `/v1/chat/completions` reply. |
| `mock.reply.text` / `.toolCall` / `.toolCalls` | Builds an `OllamaChatReply` for a handler to return. |
| `mock.requests` | The `CapturedRequestLog` — `.all`, `.count`, `.last()`, `.clear()`. |
| `CapturedChatRequest` | One captured request: `.model`, `.tools`, `.messages`, plus every `expect*`. |
| Export | What it's for |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `createOllamaMock(options?)` | Builds one mock instance. `options.models` seeds the `/api/tags` catalogue. |
| `mock.fetch` | A `(Request) => Promise<Response>` handler — the in-process path. |
| `mock.listen(port?)` | Starts a real Bun HTTP server; returns `{ url, close() }` — the server path. |
| `mock.setModels(models)` | Rewrites the catalogue mid-test (a connect flow that re-reads it). |
| `mock.onChat(handler)` | Scripts every subsequent `/v1/chat/completions` reply. |
| `mock.reply.text` / `.toolCall` / `.toolCalls` | Builds an `OllamaChatReply` for a handler to return. |
| `mock.requests` | The `CapturedRequestLog` — `.all`, `.count`, `.last()`, `.clear()`. |
| `CapturedChatRequest` | One captured request: `.model`, `.tools`, `.messages`, plus every `expect*`. |
| `mock.reply.malformedToolName` / `.toolNameOfLength` / `.textlessToolCall` / `.wrongShapedToolArgs` / `.truncatedToolArgs` / `.refusal` / `.oversized` / `.hallucinatedToolName` | The adversarial output catalogue — see above. |
| `sequence(replies)` | Scripts one reply per turn for `onChat`; repeats the last once exhausted. |

Routes covered: `GET /api/tags` (native catalogue), `POST /api/show`
(per-model capability probe), `POST /v1/chat/completions`
Expand All @@ -147,11 +197,10 @@ the openai adapter's request-building actually call. No `/api/generate`,
- **OpenAI-compatible and Anthropic provider mocks** — `@corbits/mocks/openai`
and `@corbits/mocks/anthropic`, same request-capture and reply-scripting
API shape as this one.
- **Adversarial output catalogue** — seeded canned failures a handler can
return in one call: malformed tool names (`\n</parameter` embedded in a
function name), truncated JSON tool arguments, refusals, wrong-shaped
tool args, oversized outputs, a textless `inference.done`, and the
tool-name-length edges three of our real tools sit at (63/64 chars).
- **Wiring the CL-6478 regression guard through the real turn
assembler** — `cl-6478-demo.test.ts` demonstrates the shape at this mock
layer only; an equivalent test exercising `@workbench/hub-sessions`'s
actual turn assembly (vendored, out of scope for this unit) is next.
- **Converging the scattered fakes** — `packages/evals`' github MCP fake
and its stub inference harness should be rebuilt on top of this package
rather than living on beside it.
169 changes: 169 additions & 0 deletions packages/mocks/src/ollama/cl-6478-demo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Demonstrates the exact shape of test that would have caught CL-6478
// before it shipped: qwen3.8:27b emitted `\n</parameter` inside a tool-call
// function name. A turn assembler that persists that name into history
// verbatim -- as ours did -- carries the malformed fragment forward on
// every following turn; @intx/inference's encodeToolName then throws
// re-encoding it, so the room dies rebuilding its request forever.
//
// The real fix is `sanitizeToolNameForPersistence` in
// vendor/intx/hub-sessions/src/sanitize-tool-name.ts, which collapses an
// unencodable name to a stable placeholder before it is ever persisted.
// That guard is demonstrated here at the mock layer only -- wiring an
// equivalent regression test through the real hub-sessions turn assembler
// is a follow-up, not covered by this unit.
import { describe, expect, test } from "bun:test";
import { createOllamaMock, sequence } from "./index";

type ChatCompletionBody = {
choices: {
message: {
content: string | null;
tool_calls?: { function: { name: string; arguments: string } }[];
};
finish_reason: string;
}[];
};

async function chat(
fetchImpl: (req: Request) => Promise<Response>,
body: unknown,
): Promise<Response> {
return fetchImpl(
new Request("http://mock-ollama/v1/chat/completions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}

// Stands in for hub-sessions's turn assembler persisting the PRECEDING
// assistant reply -- tool_calls included -- into the next turn's history
// before sending. This is the persistence step CL-6478's fix guards.
async function assembleAndSendNextTurn(
fetchImpl: (req: Request) => Promise<Response>,
priorAssistantMessage: unknown,
nextTurnContent: string,
): Promise<Response> {
return chat(fetchImpl, {
model: "qwen3.8:27b",
tools: [{ type: "function", function: { name: "run_shell" } }],
messages: [
{ role: "system", content: "doctrine" },
{ role: "user", content: "run the sidecar bundle" },
priorAssistantMessage,
{ role: "user", content: nextTurnContent },
],
stream: false,
});
}

describe("CL-6478 regression shape", () => {
test("a turn assembler that persists the malformed name unchanged carries it into every following turn's history", async () => {
const ollama = createOllamaMock();
ollama.onChat(
sequence([
ollama.reply.malformedToolName(),
ollama.reply.text("turn 2, room still alive"),
]),
);

const turn1Response = await chat(ollama.fetch, {
model: "qwen3.8:27b",
tools: [{ type: "function", function: { name: "run_shell" } }],
messages: [
{ role: "system", content: "doctrine" },
{ role: "user", content: "run the sidecar bundle" },
],
stream: false,
});
const turn1Body = (await turn1Response.json()) as ChatCompletionBody;
const brokenToolCall = turn1Body.choices[0]?.message.tool_calls?.[0];
expect(brokenToolCall?.function.name).toContain("\n</parameter");

// The buggy assembler: append turn 1's assistant reply -- broken tool
// name included -- straight into turn 2's history, unsanitized.
await assembleAndSendNextTurn(
ollama.fetch,
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_0",
type: "function",
function: brokenToolCall?.function,
},
],
},
"did that work?",
);

const turn2Request = ollama.requests.last();

// This is the regression guard: the malformed name persisted straight
// into history is exactly what would have caught CL-6478 -- a real
// sanitizer must intercept it here, before the next turn's request is
// even built, or the room is already bricked.
expect(
turn2Request.messages.some((message) =>
message.toolCalls?.some((call) => call.name.includes("\n</parameter")),
),
).toBe(true);
});

test("with the room's history sanitized (CL-6478's fix applied), the next turn survives with a normal reply", async () => {
const ollama = createOllamaMock();
ollama.onChat(
sequence([
ollama.reply.malformedToolName(),
ollama.reply.text("turn 2, room still alive"),
]),
);

await chat(ollama.fetch, {
model: "qwen3.8:27b",
tools: [{ type: "function", function: { name: "run_shell" } }],
messages: [
{ role: "system", content: "doctrine" },
{ role: "user", content: "run the sidecar bundle" },
],
stream: false,
});

// A correct assembler runs sanitizeToolNameForPersistence (or
// equivalent) before persisting -- the malformed fragment never
// reaches history. Simulated here as the placeholder name the real
// sanitizer collapses onto (MALFORMED_TOOL_NAME in
// vendor/intx/hub-sessions/src/sanitize-tool-name.ts).
const turn2Response = await assembleAndSendNextTurn(
ollama.fetch,
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_0",
type: "function",
function: { name: "malformed_tool_call", arguments: "{}" },
},
],
},
"did that work?",
);
const turn2Body = (await turn2Response.json()) as ChatCompletionBody;

expect(turn2Body.choices[0]?.message.content).toBe(
"turn 2, room still alive",
);
expect(
ollama.requests
.last()
.messages.some((message) =>
message.toolCalls?.some((call) =>
call.name.includes("\n</parameter"),
),
),
).toBe(false);
});
});
2 changes: 2 additions & 0 deletions packages/mocks/src/ollama/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type {
CreateOllamaMockOptions,
OllamaMockServer,
} from "./mock";
export { sequence } from "./scenarios";
export type { AdversarialReplies } from "./scenarios";
export { CapturedChatRequest, CapturedRequestLog } from "./capture";
export type {
OllamaCapability,
Expand Down
7 changes: 6 additions & 1 deletion packages/mocks/src/ollama/mock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type } from "arktype";
import { CapturedChatRequest, CapturedRequestLog } from "./capture";
import { createAdversarialReplies } from "./scenarios";
import {
ChatCompletionRequestBody,
type OllamaCatalogEntry,
Expand Down Expand Up @@ -31,7 +32,10 @@ function toolCallToWire(call: OllamaToolCall, index: number) {
return {
id: `call_${index}`,
type: "function" as const,
function: { name: call.name, arguments: JSON.stringify(call.arguments) },
function: {
name: call.name,
arguments: call.rawArguments ?? JSON.stringify(call.arguments),
},
};
}

Expand Down Expand Up @@ -145,6 +149,7 @@ export class OllamaMock {
toolCalls: calls,
finishReason: "tool_calls",
}),
...createAdversarialReplies(),
};

/**
Expand Down
Loading
Loading