diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 32346ed..307b8fe 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -131,19 +131,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean) return { ...agent, graphTools: { enabled: on } }; } -/** The research tool runs on the search port — the teacher registry DROPS - * it when no engine is live (chat-handler's searchAvailable, the same - * /api/search signal the section fetches). Pure — exported for tests. - * Teacher-stream flag 2026-09-22: the settings row grays on the SAME - * signal, so the row and the actual registry availability can never - * disagree. `search === null` (loading/failed) grays NOTHING. */ -export function researchToolUnavailable( - toolId: string, - search: { available: boolean } | null, -): boolean { - return toolId === "research" && search !== null && !search.available; -} - /** Compose: one slot's model/thinking override. Empty model = absent * override (Default — falls to the factory pin); empty thinking = the * tier keeps its resolved thinking budget. Per-field independence, same @@ -792,28 +779,19 @@ export function AgentSection({ ); })()} - {TOGGLE_TOOLS.map((id) => { - // Research needs a live search engine; the row + toggle gray - // on the same signal the registry drops the tool on (see - // researchToolUnavailable). The reason rides as the hint. - const searchDown = researchToolUnavailable(id, search); - return ( - - onSectionChange(composeTool(agent, id, on))} - /> - - ); - })} + {TOGGLE_TOOLS.map((id) => ( + + onSectionChange(composeTool(agent, id, on))} + /> + + ))} {(() => { const engine = agent?.search?.engine ?? null; const anyKey = engineKeys.tavily === true || engineKeys.brave === true; diff --git a/src/agent/subagents/researcher.ts b/src/agent/subagents/researcher.ts index 943d900..c3a979f 100644 --- a/src/agent/subagents/researcher.ts +++ b/src/agent/subagents/researcher.ts @@ -49,6 +49,8 @@ Tool outputs may be error strings (e.g. search unconfigured, a page that failed Adapt: try another URL or another search angle; if research is genuinely impossible, say so in the brief instead of inventing facts. +If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources. + Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: ## Summary @@ -70,8 +72,7 @@ export interface ResearcherRunOptions { /** The search seam (search port wave, 2026-09-20): resolves the LIVE * provider per call, null = honestly unavailable (the web_search tool * reports that in its output — no crash, no silent fallback). Absent - * seam reads as unavailable: the server wiring always injects one; the - * dispatch-level drop (jobTools) keeps doomed jobs from even starting. */ + * seam reads as unavailable; web_fetch remains usable without it. */ search?: () => SearchProvider | null; /** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the * toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 042796f..41a3d40 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -82,9 +82,8 @@ export interface JobRunnerDeps { /** The search seam (search port wave, 2026-09-20): resolves the LIVE * provider fresh per job — server/search.ts composition injected by * the wiring; null = honestly unavailable (the researcher's web_search - * reports it). Absent seam (tests) reads the same. The dispatch-level - * drop (jobTools' searchAvailable) uses the same signal — jobs already - * in flight when a key disappears still fail honestly, never crash. */ + * reports it as recoverable text). Absent seam (tests) reads the same; + * research dispatch and web_fetch do not require search credentials. */ searchProvider?: () => SearchProvider | null; /** Key-presence gate (provider-key interlude, 2026-09-25): given the * resolved engine's provider + the kind's slot tier, return the diff --git a/src/agent/tools.ts b/src/agent/tools.ts index a9cc81b..e0d8d3f 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -268,8 +268,7 @@ export interface ProberToolContext extends ProbeDoneContext { jobStore: JobStore; /** Fire the background runner for a freshly created job. */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability — the research dispatch tool's honest-disable - * signal (same gate as the teacher's). Absent = available. */ + /** Retained search-status seam; research dispatch is available keylessly. */ searchAvailable?: () => boolean; /** The prober's graph-map reader (wave 7): present = the two map tools * (list_sessions / list_propositions) are registered. The composition @@ -331,13 +330,12 @@ export function createProberTools( // research + pdf are ALWAYS available in every phase — the // prober can verify facts or read learner PDFs while assessing. // demo/visual stay teach-phase-only (they need the lesson's plan - // context). Same gating as the teacher: toggle-filtered here, - // research subject to the searchAvailable gate (honestly absent - // when no engine is live). + // context). Same user-toggle filtering as the teacher; research + // remains available without a search-provider key. ...(jobs.research ? { research: jobs.research } : {}), pdf: jobs.pdf!, // The map reader (wave 7): present only when the settings gate - // opened — same registration-time drop as the search gate. + // opened. ...(ctx.graphStore ? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore }) : {}), @@ -400,9 +398,7 @@ export interface TeacherToolContext extends PlanToolContext { * rides along — the spawn fires mid-stream, before the tool part is * persisted to the transcript (a transcript scan would race it). */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability (search port wave, 2026-09-20): the research - * dispatch tool's honest-disable signal — no live engine = the tool - * drops from this registry (JobToolContext.searchAvailable). */ + /** Retained search-status seam; research dispatch is available keylessly. */ searchAvailable?: () => boolean; } @@ -462,10 +458,6 @@ export function createTeacherTools( find: findTool, grep: grepTool, ...planTools(ctx), - // Fix wave (2026-09-20): searchAvailable was RECEIVED here but - // DROPPED when building the inner JobToolContext — the drop-gate - // always took its ?? true default. Threaded now: the live signal - // reaches jobTools' research gate. ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }), }, withoutMechanics(enabled), diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts index edbb9d1..00a78ca 100644 --- a/src/agent/tools/jobs.ts +++ b/src/agent/tools/jobs.ts @@ -23,11 +23,9 @@ export interface JobToolContext { * input rides along because the spawn happens MID-STREAM, before * onFinish persists the tool part — a transcript scan would race it. */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability (search port wave, 2026-09-20): the honest - * disable signal for the research dispatch tool — no live engine - * means NO research tool in the registry (owner decision: registry- - * level drop, no doomed dispatch, no silent fallback). Absent = - * available (tests + any seam without search wiring). */ + /** Search-provider availability, retained for existing composition seams. + * Research dispatch does not depend on it: web_fetch is keyless and + * web_search reports a missing provider as a recoverable tool result. */ searchAvailable?: () => boolean; } @@ -127,25 +125,20 @@ const PDF_DESCRIPTION = * context (chatId + jobStore + the spawn seam), so concurrent chats never * share state. chat-handler wires it each turn; tests wire their own. * - * The research tool drops from the registry when ctx.searchAvailable reports - * no live engine (2026-09-20) — the teacher never sees it, so a keyless - * server can't dispatch doomed research jobs. Jobs ALREADY in flight when - * a key disappears still run to their honest failure (runner backstop). + * Research remains available without a search-provider key; the role's + * existing user-toggle filtering still applies outside this registry. */ export function jobTools(ctx: JobToolContext): Record> { - const searchOn = ctx.searchAvailable?.() ?? true; return { - ...(searchOn && { - research: { - name: "research", - label: "research", - description: RESEARCH_DESCRIPTION, - parameters: researchInputSchema, - execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) => - dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), - ), - }, - }), + research: { + name: "research", + label: "research", + description: RESEARCH_DESCRIPTION, + parameters: researchInputSchema, + execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) => + dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), + ), + }, demo: { name: "demo", label: "demo", diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index b257c19..a3eca54 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -40,8 +40,8 @@ export interface PracticeToolContext { graphNodes?: () => ReadonlyArray>; /** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers * behind the SAME settings gate as the prober's (graphToolsEnabled at - * the composition root); the research + pdf jobs with the honest - * searchAvailable drop. Each absent (tests) = absent from the registry. */ + * the composition root); the research + pdf jobs (both keyless). + * Each absent context (tests) = absent from the registry. */ graph?: GraphToolContext; jobs?: JobToolContext; } @@ -225,8 +225,8 @@ export function practiceTools(ctx: PracticeToolContext): Record { assert.deepEqual(search.queries, ["sigma.js latest version (5)"]); }); + it("a keyless fetch-only researcher still completes and delivers a sourced job result", async () => { + const url = "https://example.com/supplied-source"; + const brief = `## Summary\nVerified supplied source. [Source](${url})\n\n## Sources\n- Source (${url})`; + const input = { question: `Verify ${url}` }; + const chat = chatStore.createChat("Test: keyless fetch-only research"); + trackChat(chat.id); + chatStore.saveMessages(chat.id, nativeToolMessages("verify supplied URL", "research", "call-keyless", input)); + const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-keyless" }); + trackJob(job.id); + const delivery = fakeDeliver(); + const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => null }); + const fetched: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (source) => { + fetched.push(String(source)); + assert.equal(String(source), url); + return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } }); + }) as typeof fetch; + researchModelOverride.current = fauxEngine([ + fauxAssistantMessage([fauxToolCall("web_fetch", { url }, { id: "keyless-fetch" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText(brief)]), + ]); + try { + await runner.runJob(job.id); + } finally { + researchModelOverride.current = undefined; + globalThis.fetch = realFetch; + } + assert.deepEqual(fetched, [url]); + const done = jobStore.getJob(job.id)!; + assert.equal(done.status, "done"); + assert.equal((done.result as ResearchResult).brief, brief); + assert.ok((done.result as ResearchResult).sources.some((s) => s.url === url)); + assert.equal(delivery.steers.length, 1); + assert.equal(isSubagentFailure("research", done.result as ResearchResult), false); + }); + it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => { const chat = chatStore.createChat("Test: runner failure"); trackChat(chat.id); diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts index ecaa9b4..5606e17 100644 --- a/tests/practice-agents.test.ts +++ b/tests/practice-agents.test.ts @@ -208,14 +208,15 @@ describe("the practice agent", () => { const jobs = { chatId: "c1", jobStore: null as never, spawn: (() => {}) as never, searchAvailable: () => false }; const graph = { chatStore: null as never, graphStore: null as never }; const tools = practiceTools({ ...ctxOf([]), jobs, graph }); - // research honestly absent (no search engine live); pdf always; the - // prober's two map readers behind the settings gate. + // Both jobs remain available keylessly; the prober's two map + // readers stay behind their existing settings gate. Practice does + // not apply the teacher/prober user tool-toggle filter. assert.deepEqual( Object.keys(tools).sort(), - ["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read"], + ["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read", "research"], ); const withSearch = practiceTools({ ...ctxOf([]), jobs: { ...jobs, searchAvailable: () => true }, graph }); - assert.ok(withSearch.research, "a live engine adds research"); + assert.ok(withSearch.research, "a live engine also keeps research"); }); it("constructs with exactly the two practice tools", () => { diff --git a/tests/research-settings.test.tsx b/tests/research-settings.test.tsx new file mode 100644 index 0000000..0ec1bf3 --- /dev/null +++ b/tests/research-settings.test.tsx @@ -0,0 +1,70 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { parseHTML } from "linkedom"; +import { AgentSection } from "../components/settings/agent-section.tsx"; +import type { AgentSettings } from "../src/core/index.ts"; + +it("keyless settings keep Research interactive while search-engine setup retains its own disabled state", async () => { + const dom = parseHTML("
"); + const globals = globalThis as Record; + const previous = new Map(); + const reason = "No search engine is configured."; + const bodies: Record = { + "/api/search": { available: false, engine: null, reason }, + "/api/keys": { keys: [{ id: "TAVILY_API_KEY", present: false }, { id: "BRAVE_API_KEY", present: false }] }, + "/api/models": { models: [], slots: { strong: null, cheap: null, vision: null } }, + "/api/settings/prompts": { prober: [], teacher: [] }, + }; + for (const [key, value] of Object.entries({ + window: dom.window, + document: dom.document, + navigator: globals.navigator ?? { userAgent: "", platform: "" }, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + fetch: async (url: string) => { + assert.ok(url in bodies, `unexpected request: ${url}`); + return new Response(JSON.stringify(bodies[url])); + }, + })) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")!); + let written: AgentSettings | undefined; + try { + await act(async () => { + root.render( { written = next; }} + onModelsChange={() => {}} + onCompactionChange={() => {}} + />); + }); + const row = document.querySelector('[data-testid="tool-row-research"]')!; + const toggle = row.querySelector('[data-testid="toggle-tool-research"]')!; + assert.ok(toggle); + assert.equal(toggle.hasAttribute("disabled"), false); + assert.equal(toggle.getAttribute("aria-checked"), "true"); + assert.ok(!row.className.includes("opacity-40")); + assert.ok(!row.textContent?.includes(reason), "search setup hint is no longer a research-disable hint"); + act(() => { toggle.click(); }); + assert.equal(written?.tools?.research, false, "the keyless toggle emits the same stored boolean"); + const engine = document.querySelector('[data-testid="search-engine-trigger"]')!; + assert.ok(engine.className.includes("cursor-not-allowed"), "search engine preference retains its own key gate"); + act(() => { engine.click(); }); + assert.equal(document.querySelector('[data-testid="search-engine-menu"]'), null, "keyless engine preference does not open"); + assert.ok(document.querySelector('[data-testid="search-engine-row"]')?.textContent?.includes(reason)); + } finally { + act(() => { root.unmount(); }); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globals[key]; + } + } +}); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index 80a241b..d629349 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -15,7 +15,6 @@ import { composeSlotModel, composeTool, composeQuizMechanics, - researchToolUnavailable, sectionedSegments, } from "../components/settings/agent-section.tsx"; import { nextOverrides } from "../components/settings/keyboard-section.tsx"; @@ -328,21 +327,6 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", ( }); }); -describe("researchToolUnavailable — the row grays on the registry's own signal", () => { - it("research grays exactly when the live search status says unavailable", () => { - // the SAME /api/search signal chat-handler's searchAvailable uses — - // the row and the registry can never disagree (Teacher flag 2026-09-22) - assert.equal(researchToolUnavailable("research", { available: false }), true); - assert.equal(researchToolUnavailable("research", { available: true }), false); - // loading/failed fetch grays nothing (never a false-negative row) - assert.equal(researchToolUnavailable("research", null), false); - // only research — the other tools never touch the search port - for (const id of ["demo", "visual", "pdf"]) { - assert.equal(researchToolUnavailable(id, { available: false }), false); - } - }); -}); - describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => { // The server contract: BOTH per-agent lists include the shared // segments (same paths) — the view must list them ONCE. diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 62ba397..46a7248 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -1,20 +1,14 @@ -/** - * 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). - * +/** Keyless research availability + the injected search port. * No network, no shared data files — the jobStore fake below is in-memory. + * A missing search key is a recoverable tool result, not a dispatch gate. */ 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 { runResearcherAgent } from "../src/agent/subagents/researcher.ts"; +import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; 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"; @@ -31,9 +25,13 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/", 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); + assert.ok(web_search, "web_search is still exposed without a provider"); const out = await web_search!.execute!("c1", { query: "anything" }); - assert.match(String(out.details), /not available: no search engine is configured/); - assert.match(String(out.details), /Tavily or Brave/); + const missing = "web_search is not available: no search engine is configured. " + + "Set a Tavily or Brave search API key in settings. Report in your brief that web search is unavailable."; + assert.equal(out.details, missing); + assert.deepEqual(out.content, [{ type: "text", text: missing }]); + assert.notEqual(out.isError, true, "missing keys remain ordinary recoverable text, not a native error"); }); it("a live provider returns its hits (count defaults to 5)", async () => { @@ -110,11 +108,86 @@ describe("web_search on the injected port (web.ts)", () => { }); }); -/** Minimal in-memory JobStore — the drop tests never touch the fs. */ +describe("keyless researcher loop", () => { + it("sends the exact approved missing-credentials guidance in the researcher system prompt", async () => { + const approved = "If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources."; + const faux = createFauxCore({}); + faux.setResponses([(context) => { + const system = context.messages.find((m) => m.role === "system"); + assert.ok(system && system.role === "system"); + assert.equal(typeof system.content, "string"); + const prompt = system.content as string; + assert.ok(prompt.split("\n\n").includes(approved), "approved paragraph is passed verbatim"); + assert.match(prompt, /Break the question into 2-4 searchable facets/); + assert.match(prompt, /## Gaps/); + assert.ok(system.toolsAdded?.some((t) => t.name === "web_search")); + assert.ok(system.toolsAdded?.some((t) => t.name === "web_fetch")); + return fauxAssistantMessage([fauxText("Prompt verified; no model behavior inferred.")]); + }]); + assert.equal(await runResearcherAgent("Check the prompt only", { + engine: { model: faux.getModel(), streamFn: faux.streamSimple }, + }), "Prompt verified; no model behavior inferred."); + }); + + // These scripts prove tool/loop mechanics, not real-model compliance. + for (const searchFirst of [true, false]) { + it(searchFirst ? "recovers from missing search keys by fetching a supplied URL" : "fetch-only research needs no search credentials", async () => { + const url = "https://example.com/supplied-source"; + const brief = `## Summary\nVerified supplied source. [Source](${url})`; + const steps: FauxResponseStep[] = []; + const fetchCall = () => fauxAssistantMessage( + [fauxToolCall("web_fetch", { url }, { id: "fetch1" })], { stopReason: "toolUse" }, + ); + if (searchFirst) { + steps.push( + () => { + return fauxAssistantMessage( + [fauxToolCall("web_search", { query: "supplied source" }, { id: "search1" })], { stopReason: "toolUse" }, + ); + }, + (context) => { + const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "search1"); + assert.ok(result && result.role === "toolResult"); + assert.equal(result.isError, false, "missing keys do not abort the native loop"); + assert.match(JSON.stringify(result.content), /no search engine is configured/); + return fetchCall(); + }, + ); + } else steps.push(fetchCall()); + steps.push((context) => { + const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "fetch1"); + assert.ok(result && result.role === "toolResult"); + assert.equal(result.isError, false); + assert.match(JSON.stringify(result.content), /Synthetic verified source content/); + return fauxAssistantMessage([fauxText(brief)]); + }); + const faux = createFauxCore({}); + faux.setResponses(steps); + const realFetch = globalThis.fetch; + const fetched: string[] = []; + globalThis.fetch = (async (input) => { + fetched.push(String(input)); + assert.equal(String(input), url, "only the supplied URL is fetched"); + return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } }); + }) as typeof fetch; + try { + assert.equal(await runResearcherAgent(`Verify ${url}`, { + engine: { model: faux.getModel(), streamFn: faux.streamSimple }, + // No search seam: this is the keyless backstop, not a search stub. + }), brief); + assert.deepEqual(fetched, [url]); + } finally { + globalThis.fetch = realFetch; + } + }); + } +}); + +/** Minimal in-memory JobStore — registry tests never touch the fs. */ function fakeJobStore(): JobStore { const jobs = new Map(); return { - createJob(init) { + createJob(init) { const job: JobRecord = { id: `job_${jobs.size + 1}`, kind: init.kind, @@ -149,13 +222,28 @@ function fakeCtx(available?: () => boolean) { }; } -describe("the research registry drop (jobTools)", () => { - it("searchUnavailable: false → research drops from the registry (the other job tools stay)", () => { +describe("keyless research dispatch (jobTools)", () => { + it("searchAvailable: false → research remains in the registry", () => { const tools = jobTools(fakeCtx(() => false)); - assert.equal("research" in tools, false, "no research tool — no doomed dispatch"); + assert.ok(tools.research, "research can fetch supplied URLs without a search provider"); assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents"); }); + it("keyless dispatch preserves the async job acknowledgement and spawn contract", async () => { + const ctx = fakeCtx(() => false); + const spawned: Array<{ job: JobRecord; input: unknown }> = []; + const tools = jobTools({ ...ctx, spawn: (job, input) => { spawned.push({ job, input }); } }); + const input = { question: "Verify the supplied URL", context: "https://example.com/source" }; + const out = await tools.research!.execute!("keyless-call", input); + assert.match(String(out.details), /^Dispatched \(job job_1\)\./); + assert.match(String(out.details), /fire-and-forget/); + assert.equal(spawned.length, 1); + assert.equal(spawned[0]!.job.kind, "research"); + assert.equal(spawned[0]!.job.toolCallId, "keyless-call"); + assert.equal(spawned[0]!.job.status, "running"); + assert.deepEqual(spawned[0]!.input, input); + }); + it("searchAvailable: true → research present", () => { const tools = jobTools(fakeCtx(() => true)); assert.ok(tools.research, "a keyed server exposes research"); @@ -167,12 +255,8 @@ describe("the research registry drop (jobTools)", () => { }); }); -// ─── the threaded gate (fix wave, 2026-09-20) ──────────────────────────────── -// -// The fix: createTeacherTools/createProberTools used to DROP searchAvailable -// when building the inner JobToolContext, so jobTools' gate always took its -// ?? true default even when the live wiring said unavailable. These pins -// hold the THREADED path (the registry factories, not jobTools directly). +// Exercise live registry factories too: search status must not override +// the existing per-role/user-toggle policy. import { createProberTools, createTeacherTools } from "../src/agent/tools.ts"; import type { ChatStore } from "../src/core/ports.ts"; @@ -186,19 +270,19 @@ const fakeChatStore = { const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore }; -describe("the threaded searchAvailable gate (registry factories)", () => { - it("createTeacherTools: unavailable drops research on the live wiring path", () => { +describe("keyless role registries", () => { + it("createTeacherTools: unavailable keeps research on the live wiring path", () => { const tools = createTeacherTools({ ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false, }); - assert.equal("research" in tools, false, "the threaded signal reached jobTools' gate"); + assert.ok(tools.research, "no search key is required to dispatch research"); assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive"); }); - it("createTeacherTools: available keeps research (the gate reads the signal both ways)", () => { + it("createTeacherTools: available keeps research", () => { const tools = createTeacherTools({ ...ctxBase, jobStore: fakeJobStore(), @@ -208,7 +292,7 @@ describe("the threaded searchAvailable gate (registry factories)", () => { assert.ok(tools.research, "a keyed server exposes research through the teacher"); }); - it("createProberTools: research + pdf are phase-omnipresent; the gate drops research only", () => { + it("createProberTools: research + pdf remain phase-omnipresent with or without search keys", () => { const on = createProberTools({ ...ctxBase, deliver: () => {}, @@ -225,16 +309,25 @@ describe("the threaded searchAvailable gate (registry factories)", () => { spawn: () => {}, searchAvailable: () => false, }); - assert.equal("research" in off, false, "no engine = no research dispatch in the prober either"); + assert.ok(off.research, "keyless research dispatch is available in the prober too"); assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on"); }); - it("createProberTools: the user toggle drops research independently of the gate", () => { + it("createProberTools: the user toggle still drops research without search keys", () => { const tools = createProberTools( - { ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => true }, + { ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false }, { research: false }, ); assert.equal("research" in tools, false, "toggle-filtered like the teacher side"); assert.ok(tools.pdf, "pdf unaffected by a research toggle"); }); + + it("createTeacherTools: the user toggle still drops research without search keys", () => { + const tools = createTeacherTools( + { ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false }, + { research: false }, + ); + assert.equal("research" in tools, false); + assert.ok(tools.pdf && tools.demo && tools.visual); + }); });