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:
amosblomqvist
2026-09-22 13:59:25 +03:00
parent 48fbd988f1
commit ed264c936d
8 changed files with 385 additions and 232 deletions
+131 -145
View File
@@ -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;
}
});