feat(search): web_search onto the injected port — Google CSE retired, research honestly disabled
Chunk A of the search port wave (2026-09-20), per master's assignment: - web.ts: the Google Custom Search backend is gone — web_search now runs on the core search port (SearchProvider), injected as a dependency (import law: the tool never imports server code). createWebTools(search) resolves the LIVE provider fresh per call; null = the honest unavailability string (no silent fallback, owner decision). web_fetch unchanged (needs no engine). - runner: JobRunnerDeps.searchProvider — the server composition threaded into the researcher; jobs already in flight when a key disappears still fail honestly through the same seam, never crash. - jobTools: the research dispatch tool DROPS from the teacher's registry when ctx.searchAvailable() is false (owner decision: registry-level drop — the model never sees the tool, no doomed dispatch, no error card). Absent seam = available (tests). chat-handler injects server/searchAvailable + searchProvider. - tests: stubFetch (the Google wire stub) retired; jobs.test.ts threads a recording stub provider through all four research runners and asserts the scripted query hit the port; new web-search.test.ts covers the factory contract (null → honest string, hits, error strings, fresh resolution, web_fetch independence) + the registry drop matrix. Gates: typecheck, 793/793 tests, lint, build — all green.
This commit is contained in:
+131
-145
@@ -59,6 +59,7 @@ import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { SearchProvider } from "../src/core/search.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
const JOBS_DIR = path.join(PATHS.data, "jobs");
|
||||
@@ -357,7 +358,8 @@ describe("dispatch (async protocol §2)", () => {
|
||||
trackChat(chat.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
const search = stubSearch();
|
||||
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => search });
|
||||
const engine = researchCallingTeacher();
|
||||
const tools = createTeacherTools({
|
||||
chatId: chat.id,
|
||||
@@ -368,42 +370,41 @@ describe("dispatch (async protocol §2)", () => {
|
||||
const agent = (outgoing: UIMessage[]): TeachingAgent =>
|
||||
createTeacherAgent(undefined, { engine, tools, messages: recordToPiMessages(outgoing) });
|
||||
|
||||
const restoreFetch = stubFetch();
|
||||
researchModelOverride.current = researcherModel();
|
||||
try {
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
|
||||
|
||||
// The teacher's SAME turn carried on past the tool call.
|
||||
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
||||
// The teacher's SAME turn carried on past the tool call.
|
||||
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research");
|
||||
assert.ok(part && part.type === "tool-research", "research tool part persisted");
|
||||
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
|
||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
|
||||
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research");
|
||||
assert.ok(part && part.type === "tool-research", "research tool part persisted");
|
||||
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
|
||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
|
||||
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
||||
|
||||
// The job record exists and the background runner completed it.
|
||||
const job = jobStore.findJobByToolCallId(chat.id, "call-r1");
|
||||
assert.ok(job, "dispatch created a job record");
|
||||
trackJob(job!.id);
|
||||
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 8000, "research job completion");
|
||||
// The job record exists and the background runner completed it.
|
||||
const job = jobStore.findJobByToolCallId(chat.id, "call-r1");
|
||||
assert.ok(job, "dispatch created a job record");
|
||||
trackJob(job!.id);
|
||||
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 8000, "research job completion");
|
||||
|
||||
const done = jobStore.getJob(job!.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered");
|
||||
const done = jobStore.getJob(job!.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered");
|
||||
|
||||
// The steer carries the brief + artifact for the (UI) renderer.
|
||||
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.jobId, job!.id);
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("sigma.js v3 is current"), "steer text carries the brief");
|
||||
} finally {
|
||||
restoreFetch();
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
// The steer carries the brief + artifact for the (UI) renderer.
|
||||
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.jobId, job!.id);
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("sigma.js v3 is current"), "steer text carries the brief");
|
||||
|
||||
// Search threading (2026-09-20): the researcher's web_search ran on
|
||||
// the INJECTED port — the stub saw the scripted query, no network.
|
||||
assert.deepEqual(search.queries, ["sigma.js latest version (5)"]);
|
||||
researchModelOverride.current = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,75 +430,65 @@ function researcherModel(): PiEngine {
|
||||
]);
|
||||
}
|
||||
|
||||
function stubFetch() {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://www.googleapis.com/")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
items: [{ title: "sigma.js", link: "https://www.sigmajs.org/", snippet: "v3 released" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("unexpected fetch: " + url, { status: 500 });
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = original;
|
||||
/** A recording stub provider (search port wave, 2026-09-20): the runner's
|
||||
* injected search seam — no network, queries captured for assertions. */
|
||||
function stubSearch(): SearchProvider & { queries: string[] } {
|
||||
const queries: string[] = [];
|
||||
return {
|
||||
id: "tavily",
|
||||
async search(query: string, count: number) {
|
||||
queries.push(`${query} (${count})`);
|
||||
return [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }];
|
||||
},
|
||||
queries,
|
||||
};
|
||||
}
|
||||
|
||||
describe("research runner (async protocol §3)", () => {
|
||||
it("runJob completes the job: result + sources on the job, brief on the steer, activity recorded", async () => {
|
||||
const restoreFetch = stubFetch();
|
||||
const chat = chatStore.createChat("Test: runner completion");
|
||||
trackChat(chat.id);
|
||||
|
||||
// A dispatched research part in the stored chat (the input is also
|
||||
// what the runner falls back to on a crash-recovery re-spawn).
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool-research", toolCallId: "call-run1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const search = stubSearch();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => search });
|
||||
researchModelOverride.current = researcherModel();
|
||||
try {
|
||||
const chat = chatStore.createChat("Test: runner completion");
|
||||
trackChat(chat.id);
|
||||
|
||||
// A dispatched research part in the stored chat (the input is also
|
||||
// what the runner falls back to on a crash-recovery re-spawn).
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool-research", toolCallId: "call-run1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
researchModelOverride.current = researcherModel();
|
||||
try {
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
// The job is done, with the activity line from the tool call.
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline");
|
||||
const result = done.result as ResearchResult;
|
||||
assert.equal(result.brief, BRIEF);
|
||||
assert.ok(result.sources.length > 0, "sources parsed from the brief");
|
||||
assert.equal(result.sources[0]!.url, "https://www.sigmajs.org/docs");
|
||||
|
||||
// The activity trail survives on... the last recorded activity is
|
||||
// the deliver flip, but the search line was recorded during the run:
|
||||
// the job store keeps only the latest; the search fired before it.
|
||||
// (We ran the search; the runner recorded it — check the store's
|
||||
// history via the final state only.)
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
restoreFetch();
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
// The job is done, with the activity line from the tool call.
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline");
|
||||
const result = done.result as ResearchResult;
|
||||
assert.equal(result.brief, BRIEF);
|
||||
assert.ok(result.sources.length > 0, "sources parsed from the brief");
|
||||
assert.equal(result.sources[0]!.url, "https://www.sigmajs.org/docs");
|
||||
|
||||
// The activity trail: the last recorded activity is the deliver
|
||||
// flip; the search line fired before it. The search itself ran on
|
||||
// the INJECTED port — the stub saw the query (no network):
|
||||
assert.deepEqual(search.queries, ["sigma.js latest version (5)"]);
|
||||
});
|
||||
|
||||
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
||||
@@ -542,37 +533,37 @@ describe("research runner (async protocol §3)", () => {
|
||||
});
|
||||
|
||||
it("runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)", async () => {
|
||||
const restoreFetch = stubFetch();
|
||||
const chat = chatStore.createChat("Test: transcript input fallback");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-fb1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({
|
||||
chatStore,
|
||||
jobStore,
|
||||
deliver: delivery.deliver,
|
||||
searchProvider: () => stubSearch(),
|
||||
});
|
||||
researchModelOverride.current = researcherModel();
|
||||
try {
|
||||
const chat = chatStore.createChat("Test: transcript input fallback");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-fb1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
|
||||
trackJob(job.id);
|
||||
|
||||
const delivery = fakeDeliver();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
|
||||
researchModelOverride.current = researcherModel();
|
||||
try {
|
||||
// no input passed — as a recovery re-spawn would call it
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done", "the researcher ran from the transcript-resolved input");
|
||||
assert.equal((done.result as ResearchResult).brief, BRIEF);
|
||||
// no input passed — as a recovery re-spawn would call it
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
restoreFetch();
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done", "the researcher ran from the transcript-resolved input");
|
||||
assert.equal((done.result as ResearchResult).brief, BRIEF);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1203,33 +1194,28 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
});
|
||||
|
||||
it("the usage folded so far survives the kill (money channel outlives the abort)", async () => {
|
||||
const restoreFetch = stubFetch();
|
||||
const { jobId } = newChatWithResearchJob("abort keeps usage", "call-abort2");
|
||||
setTurnRunner(async () => 200);
|
||||
researchModelOverride.current = oneStepThenHangModel();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {}, searchProvider: () => stubSearch() });
|
||||
try {
|
||||
const { jobId } = newChatWithResearchJob("abort keeps usage", "call-abort2");
|
||||
setTurnRunner(async () => 200);
|
||||
researchModelOverride.current = oneStepThenHangModel();
|
||||
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {} });
|
||||
try {
|
||||
const settled = runner.runJob(jobId, RESEARCH_INPUT);
|
||||
await waitFor(() => (jobStore.getJob(jobId)!.usage?.steps ?? 0) === 1, 8000, "first step folded");
|
||||
abortJob(jobId);
|
||||
await settled;
|
||||
const job = jobStore.getJob(jobId)!;
|
||||
assert.equal(job.status, "error");
|
||||
assert.equal(job.error, "Aborted by learner");
|
||||
assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally");
|
||||
// model slots wave: the stamp is the ENGINE's actual model,
|
||||
// provider-qualified — the scripted faux engine here.
|
||||
assert.equal(job.usage!.model, "faux:faux-1");
|
||||
// faux estimates tokens from the prompt/response text — the
|
||||
// pinned fact is that the ONE completed step's spend survived.
|
||||
assert.ok(job.usage!.inputTokens! > 0, "input tokens billed");
|
||||
assert.ok(job.usage!.outputTokens! > 0, "output tokens billed");
|
||||
} finally {
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
const settled = runner.runJob(jobId, RESEARCH_INPUT);
|
||||
await waitFor(() => (jobStore.getJob(jobId)!.usage?.steps ?? 0) === 1, 8000, "first step folded");
|
||||
abortJob(jobId);
|
||||
await settled;
|
||||
const job = jobStore.getJob(jobId)!;
|
||||
assert.equal(job.status, "error");
|
||||
assert.equal(job.error, "Aborted by learner");
|
||||
assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally");
|
||||
// model slots wave: the stamp is the ENGINE's actual model,
|
||||
// provider-qualified — the scripted faux engine here.
|
||||
assert.equal(job.usage!.model, "faux:faux-1");
|
||||
// faux estimates tokens from the prompt/response text — the
|
||||
// pinned fact is that the ONE completed step's spend survived.
|
||||
assert.ok(job.usage!.inputTokens! > 0, "input tokens billed");
|
||||
assert.ok(job.usage!.outputTokens! > 0, "output tokens billed");
|
||||
} finally {
|
||||
restoreFetch();
|
||||
researchModelOverride.current = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Search rewiring (search port wave, 2026-09-20) — the web_search tool's
|
||||
* port injection + the research dispatch tool's registry-level drop.
|
||||
*
|
||||
* Google CSE is dead: the tool runs on the injected search port
|
||||
* (core/search.ts), the runner threads the seam (searchProvider — covered
|
||||
* end-to-end in jobs.test.ts), and jobTools drops the research tool when
|
||||
* ctx.searchAvailable reports no live engine (owner decision: registry
|
||||
* drop, no doomed dispatch, no silent fallback).
|
||||
*
|
||||
* No network, no shared data files — the jobStore fake below is in-memory.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createWebTools } from "../src/agent/tools/web.ts";
|
||||
import { jobTools } from "../src/agent/tools/jobs.ts";
|
||||
import type { JobStore } from "../src/core/ports.ts";
|
||||
import type { JobRecord } from "../src/core/contracts.ts";
|
||||
import type { SearchHit, SearchProvider } from "../src/core/search.ts";
|
||||
|
||||
function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/", snippet: "s" }]): SearchProvider {
|
||||
return {
|
||||
id: "tavily",
|
||||
async search() {
|
||||
return hits;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("web_search on the injected port (web.ts)", () => {
|
||||
it("null provider → the honest unavailability string, never a crash", async () => {
|
||||
const { web_search } = createWebTools(() => null);
|
||||
const out = await web_search.execute!({ query: "anything" }, { toolCallId: "c1" });
|
||||
assert.match(String(out), /not available: no search engine is configured/);
|
||||
assert.match(String(out), /Tavily or Brave/);
|
||||
});
|
||||
|
||||
it("a live provider returns its hits (count defaults to 5)", async () => {
|
||||
const seen: Array<{ query: string; count: number }> = [];
|
||||
const provider: SearchProvider = {
|
||||
id: "tavily",
|
||||
async search(query, count) {
|
||||
seen.push({ query, count });
|
||||
return [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }];
|
||||
},
|
||||
};
|
||||
const { web_search } = createWebTools(() => provider);
|
||||
const out = await web_search.execute!({ query: "sigma.js latest version" }, { toolCallId: "c1" });
|
||||
assert.deepEqual(seen, [{ query: "sigma.js latest version", count: 5 }]);
|
||||
assert.deepEqual(out, [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }]);
|
||||
});
|
||||
|
||||
it("provider errors surface as error strings, never thrown", async () => {
|
||||
const provider: SearchProvider = {
|
||||
id: "brave",
|
||||
async search() {
|
||||
throw new Error("Brave search API 429: rate limited");
|
||||
},
|
||||
};
|
||||
const { web_search } = createWebTools(() => provider);
|
||||
const out = await web_search.execute!({ query: "q" }, { toolCallId: "c1" });
|
||||
assert.match(String(out), /web_search failed: Brave search API 429: rate limited/);
|
||||
});
|
||||
|
||||
it("the resolution is FRESH per call — a key appearing mid-session takes effect", async () => {
|
||||
let provider: SearchProvider | null = null;
|
||||
const { web_search } = createWebTools(() => provider);
|
||||
const first = await web_search.execute!({ query: "q" }, { toolCallId: "c1" });
|
||||
assert.match(String(first), /not available/);
|
||||
provider = stubProvider();
|
||||
const second = await web_search.execute!({ query: "q" }, { toolCallId: "c2" });
|
||||
assert.ok(Array.isArray(second), "the live provider answered on the very next call");
|
||||
});
|
||||
|
||||
it("web_fetch needs no search engine — it stays independent", async () => {
|
||||
const { web_fetch } = createWebTools(() => null);
|
||||
assert.ok(web_fetch, "web_fetch present with no provider");
|
||||
// (Full fetch behavior is out of scope here — no network in tests.)
|
||||
const out = await web_fetch.execute!({ url: "not a url" }, { toolCallId: "c1" });
|
||||
assert.match(String(out), /web_fetch failed: invalid URL/);
|
||||
});
|
||||
});
|
||||
|
||||
/** Minimal in-memory JobStore — the drop tests never touch the fs. */
|
||||
function fakeJobStore(): JobStore {
|
||||
const jobs = new Map<string, JobRecord>();
|
||||
return {
|
||||
createJob(init) {
|
||||
const job: JobRecord = {
|
||||
id: `job_${jobs.size + 1}`,
|
||||
kind: init.kind,
|
||||
chatId: init.chatId,
|
||||
toolCallId: init.toolCallId,
|
||||
status: "running",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
activity: "",
|
||||
} as JobRecord;
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
},
|
||||
getJob: (id) => jobs.get(id) ?? null,
|
||||
updateJob: (id, patch) => {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return null;
|
||||
return Object.assign(job, patch);
|
||||
},
|
||||
listJobs: () => [...jobs.values()],
|
||||
findJobByToolCallId: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx(available?: () => boolean) {
|
||||
return {
|
||||
chatId: "chat-drop-test",
|
||||
jobStore: fakeJobStore(),
|
||||
spawn: () => {},
|
||||
...(available ? { searchAvailable: available } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("the research registry drop (jobTools)", () => {
|
||||
it("searchUnavailable: false → research drops from the registry (the other job tools stay)", () => {
|
||||
const tools = jobTools(fakeCtx(() => false));
|
||||
assert.equal("research" in tools, false, "no research tool — no doomed dispatch");
|
||||
assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents");
|
||||
});
|
||||
|
||||
it("searchAvailable: true → research present", () => {
|
||||
const tools = jobTools(fakeCtx(() => true));
|
||||
assert.ok(tools.research, "a keyed server exposes research");
|
||||
});
|
||||
|
||||
it("absent seam = available (tests + any wiring without search)", () => {
|
||||
const tools = jobTools(fakeCtx());
|
||||
assert.ok(tools.research, "the seam is optional — default is honest availability");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user