From c9affbf298b1be33d56cefeb89b696cf44b574cf Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 20:25:10 -0700 Subject: [PATCH 1/4] Tell a Bot what it holds, not only hand it the tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool array tells a model a tool exists. It does not tell it the tool is the right way to reach that system, and it competes with COMPUTER_GUIDANCE — a page of emphatic prose about the browser that every Bot receives whether or not it has a single connector, and that mentions connectors nowhere. "Never claim you cannot browse" is in there. Nothing is. The browser prose won. A Bot holding four Google Drive tools browsed to drive.google.com, met a sign-in page its container can never satisfy, and asked its person to sign in to a vendor that person had already connected. The tools were loaded the whole time. So the grants are now said out loud, in the Bot's own instructions, and before the browser prose rather than after it. The text is generated from the grants rather than written anywhere: switching a connector on, or granting one more of its tools, changes what the Bot is told on its next run with nothing to keep in step. A Bot holding nothing is told nothing. Both paths, because they are built by different functions and the remote one is where it failed: a framework Bot gets COMPUTER_GUIDANCE as its entire prompt, so it had the least reason of any to reach for a tool. Verified against the document rather than the answer. Read a PRD's text through the API first, took a line out of it — "Kai reached 83% company-wide" against weekly active users — then asked in the chat. The Bot answered 83% and named the metric, using search_files, search_files, read_file_content, and never touching the browser. Two minutes earlier, on the same trail, a Bot without those grants met the sign-in wall. --- server/src/copilot.ts | 42 ++++++++++++++++++++++--- server/src/plugins/tools.ts | 42 +++++++++++++++++++++++++ server/tests/copilot.test.ts | 61 ++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 766ce4f2..8582a85d 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -8,6 +8,7 @@ import { import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; import { z } from "zod"; import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; +import { grantedToolGuidance } from "./plugins/tools"; import type { AgentActor } from "./agents/profile-types"; import type { StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; @@ -200,9 +201,18 @@ export function builtInAgentConfiguration( return { model: `${model.provider}/${model.defaultModel}`, - prompt: computerGuidance - ? `${agent.systemPrompt}\n\n${computerGuidance}` - : agent.systemPrompt, + /* + * The package's role, then what this Bot actually holds, then the computer. + * + * The grants go BEFORE the computer prose on purpose. That prose is long and emphatic about the + * browser and mentions connectors nowhere, so a Bot that read it last reached for the browser + * even when it held a tool for the exact system being asked about. + */ + prompt: [ + agent.systemPrompt, + ...(grantedToolGuidance(tools) ? [grantedToolGuidance(tools)] : []), + ...(computerGuidance ? [computerGuidance] : []), + ].join("\n\n"), apiKey, /* * A run stops after one step unless told otherwise, which for a Bot with tools means it calls @@ -327,13 +337,37 @@ function remoteAgentWithStandingRole( ? { fetch: stallGuard.watch({ id: agent.id, name: agent.name }) } : {}), }); + /* + * What this Bot holds, as a second standing message. + * + * Beside the role rather than inside it, because the role comes from the package and this comes + * from the grants: they change for different reasons and at different times. Sent on every run for + * the same reason the tools are, so switching a connector on reaches the next run. + * + * The remote path needs this more than the built-in one, not less. A framework Bot is handed the + * tools as an offer and decides for itself what to call, with `COMPUTER_GUIDANCE` as its whole + * prompt — a page about the browser that mentions connectors nowhere. That is the Bot that browsed + * to drive.google.com holding four Drive tools. + */ + const holdings = grantedToolGuidance(tools); + const holdingsMessage = holdings + ? { + id: `granted-tools:${agent.id}`, + role: "system" as const, + content: holdings, + } + : null; + remote.use((input, next) => next.run({ ...input, messages: [ agent.standingMessage, + ...(holdingsMessage ? [holdingsMessage] : []), ...input.messages.filter( - (message) => message.id !== agent.standingMessage.id, + (message) => + message.id !== agent.standingMessage.id && + message.id !== holdingsMessage?.id, ), ], /* diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 0bc35518..35aa4753 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -48,6 +48,48 @@ export function parametersFor(inputSchema: Record): z.ZodType { return z.object({}).catchall(z.unknown()); } +/** + * What this Bot holds, said in its instructions rather than left to be inferred from a tool list. + * + * A tool array tells a model a tool exists. It does not tell it that the tool is the right way to + * reach that system, and it competes with a page of prose about the browser that every Bot is given + * whether or not it has any connectors at all. The browser prose wins: it is emphatic, it is about + * capability, and it says "never claim you cannot browse". + * + * So a Bot holding four Google Drive tools browsed to drive.google.com, met a sign-in page its + * container could never satisfy, and asked its person to sign in to a vendor that person had already + * connected. The tools were there the whole time. + * + * Generated from the grants rather than written down, because the point is that it tracks them. An + * administrator switching a connector on, or granting one more of its tools, changes what the Bot is + * told on its next run with nothing else to remember and nothing to keep in step. + * + * Empty when the Bot holds nothing, so a deployment with no connectors says nothing about them. + */ +export function grantedToolGuidance(tools: GrantedTool[]): string { + if (tools.length === 0) return ""; + + const bySystem = new Map(); + for (const tool of tools) { + // `mcp__server__tool`, which is the shape the model is offered. + const parts = tool.name.replace(/^mcp__/, "").split("__"); + const system = parts.length > 1 ? (parts[0] as string) : "this deployment"; + const rest = parts.length > 1 ? parts.slice(1).join("__") : tool.name; + bySystem.set(system, [...(bySystem.get(system) ?? []), rest]); + } + + return [ + "You can reach these systems directly, as the person asking, with their own access:", + ...[...bySystem.entries()].map( + ([system, names]) => `- ${system}: ${names.join(", ")}`, + ), + "Use them for anything about those systems. Do NOT browse to one of their websites instead: your", + "browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall", + "that connecting an account has already solved. If one of these systems is involved but no tool", + "above covers the part you need, say which part is missing rather than going around it.", + ].join("\n"); +} + /** * Every MCP tool granted to one Bot, ready to hand to the runtime. * diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 5bd14d47..ee623640 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -9,6 +9,7 @@ import { resolveRuntimeAgents, standingRoleMessage, } from "../src/copilot"; +import { grantedToolGuidance } from "../src/plugins/tools"; // Every agent row now joins its profile, so the row a coworker is built from always names it. const assistantRow = { @@ -502,3 +503,63 @@ function fakeAgUiEndpoint() { [Symbol.asyncDispose]: () => server.stop(true), }; } + +/** + * A Bot is told what it holds, not only handed it. + * + * A tool array tells a model a tool exists. It does not say the tool is the right way to reach that + * system, and it competes with `COMPUTER_GUIDANCE`: a page of emphatic prose about the browser that + * every Bot gets whether or not it has a single connector, and that mentions connectors nowhere. + * + * The browser prose won. A Bot holding four Google Drive tools browsed to drive.google.com, met a + * sign-in page its container could never satisfy, and asked its person to sign in to a vendor that + * person had already connected. Asked a question with no tool for it, another went reading a + * government website and looped on its 404 page. + * + * Both kinds are asserted because they are built by different functions, and the remote one is the + * one that failed in the product. + */ +describe("what a Bot is told it holds", () => { + const drive = [ + { name: "mcp__google-drive__search_files" }, + { name: "mcp__google-drive__read_file_content" }, + ] as never[]; + + test("names the system and its tools", () => { + const guidance = grantedToolGuidance(drive); + expect(guidance).toContain("google-drive"); + expect(guidance).toContain("search_files"); + expect(guidance).toContain("read_file_content"); + }); + + test("says not to browse to a vendor it has a tool for", () => { + // The whole point. Without this line the tool list is inert beside the browser prose. + expect(grantedToolGuidance(drive).toLowerCase()).toContain("do not browse"); + }); + + test("says nothing at all when the Bot holds nothing", () => { + // A deployment with no connectors must not be told about connectors it does not have. + expect(grantedToolGuidance([])).toBe(""); + }); + + test("a built-in Bot is told before it is told about the browser", () => { + const prompt = builtInAgentConfiguration( + { + id: "risk-analyst", + name: "Risk Analyst", + type: "built_in", + systemPrompt: "Investigate policies.", + }, + { provider: "openai", defaultModel: "gpt-4.1" }, + "openai-secret", + drive, + "BROWSER GUIDANCE HERE", + ).prompt as string; + + // Order is the fix, not merely presence: the grants have to land before the browser prose. + expect(prompt.indexOf("google-drive")).toBeGreaterThan(-1); + expect(prompt.indexOf("google-drive")).toBeLessThan( + prompt.indexOf("BROWSER GUIDANCE HERE"), + ); + }); +}); From b946171030b2cc5113ed46d11bd7f3411498e922 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 20:31:30 -0700 Subject: [PATCH 2/4] Route on what a coworker can reach, not only what it is for The router saw an id, a name and a role description. So it routed on what somebody wrote a coworker was for, which is not the same as what it can do. Asked what was in a Google Drive document, it chose the coworker whose description says "company knowledge questions" and which held no Drive grants at all. That coworker browsed to drive.google.com, met a sign-in page its container can never satisfy, and asked the person to sign in to an account they had already connected. The coworker that could have answered was one line further down the same roster. Candidates now carry the systems they hold tools for, and the prompt says to prefer one that can reach what the message names. Purpose still comes first: a specialist with no connectors is still right for a question about its specialism, and the instruction says so, because the opposite failure is sending everything to whoever happens to hold a connector. Asked per request rather than held, so a grant added a minute ago counts. A store that is slow or unhappy is treated as holding nothing, because a connector lookup must never turn "who is this for" into an error. A deployment with no connectors gets the prompt it got before, down to the instruction line, which is not added when nothing on the roster can reach anything. Driven: the same question that went to Knowledge now goes to Risk Analyst, with no @ mention, and the recorded reason is "Google Drive access and investigation of company-wide metrics". It then answered from the document. --- server/src/app.ts | 19 +++++++++ server/src/routing/classify.ts | 37 ++++++++++++++++- server/src/routing/routes.ts | 32 +++++++++++--- server/tests/routing-classify.test.ts | 60 +++++++++++++++++++++++++++ server/tests/routing.test.ts | 0 5 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 server/tests/routing.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 327430d3..23595c94 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -659,6 +659,25 @@ export function createApp( intentRouter, requireUser, auditStore, + /* + * Which vendors each coworker holds tools for, so the router weighs what a coworker can + * reach and not only what somebody wrote it was for. Only when there is a plugin store to + * ask: a deployment with no connectors routes exactly as it did. + */ + pluginStore + ? async (agentId) => { + const granted = await pluginStore.listForAgent(agentId); + return [ + ...new Set( + granted.tools.map( + (tool) => + tool.toolName.replace(/^mcp__/, "").split("__")[0] ?? + tool.toolName, + ), + ), + ]; + } + : undefined, ), ); } diff --git a/server/src/routing/classify.ts b/server/src/routing/classify.ts index 84377337..65905c21 100644 --- a/server/src/routing/classify.ts +++ b/server/src/routing/classify.ts @@ -18,6 +18,20 @@ export type RoutingCandidate = { name: string; /** What this coworker is for. The one line an operator wrote to say when to reach them. */ roleDescription: string; + /** + * The systems this coworker can actually reach, by name. + * + * Routing on the role description alone routes on what somebody wrote a coworker was for, which is + * not the same as what it can do. A question about a document in Google Drive went to the coworker + * whose description says "company knowledge" and which held no Drive grants at all, so it browsed + * to the vendor, met a sign-in wall and asked the person to sign in to an account they had already + * connected. The coworker that could have answered was one line further down the roster. + * + * Empty for a coworker holding nothing, which is most of them in most deployments. It is a hint + * rather than a filter: a specialist with no connectors is still the right answer to a question + * about its specialism. + */ + reaches?: readonly string[]; }; export type RoutingDecision = { @@ -37,7 +51,16 @@ export function routingPrompt( candidates: readonly RoutingCandidate[], ): string { const roster = candidates - .map((c) => `- id: ${c.id}\n name: ${c.name}\n for: ${c.roleDescription}`) + .map((c) => + [ + `- id: ${c.id}`, + ` name: ${c.name}`, + ` for: ${c.roleDescription}`, + ...(c.reaches && c.reaches.length > 0 + ? [` can reach: ${c.reaches.join(", ")}`] + : []), + ].join("\n"), + ) .join("\n"); return [ "You route a person's message to the one coworker best suited to it.", @@ -46,6 +69,18 @@ export function routingPrompt( "", 'Reply with only JSON: {"agentId": "", "reason": "", "confidence": <0..1>}.', "Pick the specialist whose purpose matches the message. If none clearly fits, use the most general coworker and give it a low confidence.", + /* + * Only when somebody on the roster can actually reach something. + * + * A deployment with no connectors would otherwise carry a rule about systems none of its + * coworkers have, in every routing prompt it ever sends. Same principle as the guidance a Bot + * gets about its own grants: say nothing about what is not there. + */ + ...(candidates.some((c) => c.reaches && c.reaches.length > 0) + ? [ + "When the message names a system a coworker can reach, prefer that coworker: one that cannot reach it has no way to answer and will fall back to a browser that is signed in as nobody. Purpose still comes first — a specialist with no systems listed is right for a question about its specialism.", + ] + : []), "", `Message: ${text}`, ].join("\n"); diff --git a/server/src/routing/routes.ts b/server/src/routing/routes.ts index c884d4d1..f951ac45 100644 --- a/server/src/routing/routes.ts +++ b/server/src/routing/routes.ts @@ -21,6 +21,14 @@ export function createRoutingRoutes( router: IntentRouter, requireUser: MiddlewareHandler<{ Variables: AppVariables }>, auditStore?: AuditStore, + /** + * Which systems a coworker can reach, for the router to weigh alongside what it is for. + * + * Optional, and absent leaves routing exactly as it was: a deployment with no connectors has + * nothing to add here, and one that cannot answer the question should not have routing fail over + * it. Asked per request rather than held, because a grant added a minute ago has to count. + */ + reachableSystems?: (agentId: string) => Promise, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -39,11 +47,25 @@ export function createRoutingRoutes( if (!preferred) { return context.json({ error: "No coworker is available." }, 409); } - const candidates: RoutingCandidate[] = roster.map((a) => ({ - id: a.id, - name: a.name, - roleDescription: a.roleDescription, - })); + const candidates: RoutingCandidate[] = await Promise.all( + roster.map(async (a) => ({ + id: a.id, + name: a.name, + roleDescription: a.roleDescription, + /* + * Never allowed to break routing. A connector store that is slow or unhappy must not turn + * "who is this for" into an error, so a failure here is the same as holding nothing: the + * router falls back to matching on purpose alone, which is what it did before. + */ + ...(reachableSystems + ? { + reaches: await reachableSystems(a.id).catch( + () => [] as readonly string[], + ), + } + : {}), + })), + ); const decision = await router.route(text, candidates, preferred.id); diff --git a/server/tests/routing-classify.test.ts b/server/tests/routing-classify.test.ts index ef93da24..75a1271f 100644 --- a/server/tests/routing-classify.test.ts +++ b/server/tests/routing-classify.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createIntentRouter, + routingPrompt, type RoutingCandidate, } from "../src/routing/classify"; @@ -100,3 +101,62 @@ describe("routing a message with no @mention", () => { expect(r.fallback).toBe(true); }); }); + +/** + * The router is told what each coworker can reach, not only what it is for. + * + * Routing on the role description alone routes on what somebody wrote a coworker was for, which is + * not the same as what it can do. A question about a document in Google Drive went to the coworker + * whose description says "company knowledge" and which held no Drive grants at all. It browsed to + * the vendor, met a sign-in wall, and asked the person to sign in to an account they had already + * connected. The coworker that could have answered was one line further down the roster. + */ +describe("routing on what a coworker can reach", () => { + const withReach: RoutingCandidate[] = [ + { + id: "knowledge", + name: "Knowledge", + roleDescription: "company knowledge questions", + }, + { + id: "risk-analyst", + name: "Risk Analyst", + roleDescription: "risk and compliance", + reaches: ["google-drive"], + }, + ]; + + test("names the systems in the roster the model is given", () => { + const prompt = routingPrompt("what is in my Drive doc?", withReach); + expect(prompt).toContain("can reach: google-drive"); + }); + + test("says nothing about reach for a coworker that holds nothing", () => { + // Most coworkers in most deployments. An empty line here would be noise in every prompt. + const prompt = routingPrompt("anything", withReach); + const knowledgeBlock = prompt.slice( + prompt.indexOf("id: knowledge"), + prompt.indexOf("id: risk-analyst"), + ); + expect(knowledgeBlock).not.toContain("can reach"); + }); + + test("tells the model to prefer reach without letting it override purpose", () => { + /* + * Both halves matter. Preferring a coworker that can reach the system is the fix; letting that + * outrank purpose would send every question to whichever coworker happens to hold a connector, + * which is a different bug with the same shape. + */ + const prompt = routingPrompt("anything", withReach); + expect(prompt).toContain("prefer that coworker"); + expect(prompt).toContain("Purpose still comes first"); + }); + + test("a roster with no reach at all reads exactly as it did before", () => { + // A deployment with no connectors must not have its routing prompt changed by this. + const plain = routingPrompt("anything", [ + { id: "a", name: "A", roleDescription: "alpha" }, + ]); + expect(plain).not.toContain("can reach"); + }); +}); diff --git a/server/tests/routing.test.ts b/server/tests/routing.test.ts new file mode 100644 index 00000000..e69de29b From e8e9f00bb725522990964fbbc6efb1f565e0d166 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 20:34:11 -0700 Subject: [PATCH 3/4] Stop an unanswered request for the wheel outliving its run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control belongs to a Bot's computer, not to the conversation that asked for it, and a request nobody answered sat on that computer forever. The run that made it had already ended. Everything else carried on. So a brand new channel, on an unrelated question, showed a live "Take control" for work the Bot was not doing, captioned "Google Docs is asking for sign-in before I can read the PRD document" — a reason written for whoever asked, rendered to whoever happened to look. In a deployment with more than one person that is somebody else's context on your screen. An unanswered ask now stops being shown after ten minutes, and its reason goes with it, since the reason is the part that leaked. Expired on read rather than on a timer: there is nothing to wake, the asking run is over, and the only thing that cares is whoever looks next. A person actually holding the wheel is never timed out. They may be halfway through typing a code, and taking the browser back mid-sign-in is worse than any stale prompt. Only the ask expires. This is the half of #140 that crosses between conversations. The other half — an unanswered tool call leaving the asking thread unusable with a 400 — is still open and needs the run to write a result when it ends. --- agent-computer/src/control.ts | 41 +++++++++++++++++++++- agent-computer/tests/control.test.ts | 52 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/agent-computer/src/control.ts b/agent-computer/src/control.ts index 1baceb09..d74913ba 100644 --- a/agent-computer/src/control.ts +++ b/agent-computer/src/control.ts @@ -21,6 +21,15 @@ export type ControlState = { reason?: string; /** True once the Bot has asked for help and no person has taken the wheel yet. */ requested: boolean; + /** + * When the Bot asked, so an unanswered request can stop being shown. + * + * A request nobody answers used to last forever. The run that made it had already ended, but the + * prompt stayed on the computer, and control belongs to the computer rather than to a conversation + * — so every later conversation with that Bot showed a live "Take control" for work it was not + * doing, with the reason the Bot gave, written for whoever asked and rendered to whoever looked. + */ + requestedAt?: string; /** * A secret the Bot is waiting for, described by its label only. * @@ -57,6 +66,16 @@ export class ControlRequestError extends Error { } export const NO_SECRET_PENDING = "Nothing is waiting for a secret."; +/** + * How long an unanswered request to take the wheel is shown for. + * + * Long enough that somebody who stepped away can still act on it, short enough that it does not + * follow the Bot into tomorrow's conversations. The run that made it is already over either way: + * nothing resumes when a person takes the wheel this late, so the value trades "still useful" against + * "still on screen" and nothing else. + */ +export const HELP_REQUEST_TTL_MS = 10 * 60 * 1000; + export const HUMAN_HAS_CONTROL = "A person has control of the computer right now. Wait for them to hand it back before acting."; export const TAKE_CONTROL_FIRST = @@ -79,8 +98,27 @@ export function createControl( }; return { - /** The current state, as the surface polls it. A copy, so a caller cannot mutate the machine. */ + /** + * The current state, as the surface polls it. A copy, so a caller cannot mutate the machine. + * + * An unanswered request is dropped once it is older than {@link HELP_REQUEST_TTL_MS}. It is + * expired on read rather than on a timer because there is nothing to wake: the run that asked + * has ended, and the only thing that cares is whoever looks next. + * + * Only ever the ASK. A person actually holding the wheel is never timed out from under them: + * they may be halfway through typing a code, and taking the browser back mid-sign-in is worse + * than any stale prompt. + */ get(): ControlState { + if ( + state.requested && + state.holder === "bot" && + state.requestedAt && + Date.parse(now()) - Date.parse(state.requestedAt) > HELP_REQUEST_TTL_MS + ) { + const { reason: _reason, requestedAt: _at, ...rest } = state; + state = { ...rest, requested: false }; + } return { ...state }; }, @@ -94,6 +132,7 @@ export function createControl( state = { ...state, requested: true, + requestedAt: now(), reason: typeof reason === "string" && reason.trim() ? reason.trim() diff --git a/agent-computer/tests/control.test.ts b/agent-computer/tests/control.test.ts index 95570332..bf93d876 100644 --- a/agent-computer/tests/control.test.ts +++ b/agent-computer/tests/control.test.ts @@ -236,3 +236,55 @@ describe("the crappy paths: secrets", () => { ).toEqual(["secretRef", "secretSnapshotId", "secretWanted"]); }); }); + +/** + * A request nobody answered does not outlive the run that made it. + * + * Control belongs to the computer, not to a conversation, and an unanswered request used to sit on + * it forever. The run that asked had ended, but every later conversation with that Bot showed a live + * "Take control" for work it was not doing — and showed the reason the Bot gave, which is written + * for whoever asked and was being rendered to whoever looked. + * + * Seen in the product: a brand new channel, on an unrelated question, displaying "Google Docs is + * asking for sign-in before I can read the PRD document" from a conversation minutes earlier. + */ +describe("an unanswered request to take the wheel", () => { + const at = (iso: string) => () => iso; + + test("is still shown inside the window", () => { + let clock = "2026-08-22T03:00:00.000Z"; + const control = createControl(() => clock); + control.requestHelp("sign in to Drive"); + + clock = "2026-08-22T03:05:00.000Z"; + const state = control.get(); + expect(state.requested).toBe(true); + expect(state.reason).toBe("sign in to Drive"); + }); + + test("stops being shown once it is stale, and takes its reason with it", () => { + let clock = "2026-08-22T03:00:00.000Z"; + const control = createControl(() => clock); + control.requestHelp("sign in to Drive"); + + clock = "2026-08-22T03:20:00.000Z"; + const state = control.get(); + expect(state.requested).toBe(false); + // The reason is the part that leaked between conversations, so it goes too. + expect(state.reason).toBeUndefined(); + }); + + test("never takes the wheel back off a person who holds it", () => { + /* + * The one case that must not expire. Somebody may be halfway through typing a code, and pulling + * the browser back mid-sign-in is worse than any stale prompt. Only the ASK times out. + */ + let clock = "2026-08-22T03:00:00.000Z"; + const control = createControl(() => clock); + control.requestHelp("sign in to Drive"); + control.take(); + + clock = "2026-08-22T04:00:00.000Z"; + expect(control.get().holder).toBe("human"); + }); +}); From 3bd674dfbe3f2bf11076558ab055e7b57bb384d3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 20:35:45 -0700 Subject: [PATCH 4/4] Drop an unused test helper Left behind from an earlier shape of these tests, and `lint` is an error in CI rather than a warning. --- agent-computer/tests/control.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/agent-computer/tests/control.test.ts b/agent-computer/tests/control.test.ts index bf93d876..bbcc3f09 100644 --- a/agent-computer/tests/control.test.ts +++ b/agent-computer/tests/control.test.ts @@ -249,8 +249,6 @@ describe("the crappy paths: secrets", () => { * asking for sign-in before I can read the PRD document" from a conversation minutes earlier. */ describe("an unanswered request to take the wheel", () => { - const at = (iso: string) => () => iso; - test("is still shown inside the window", () => { let clock = "2026-08-22T03:00:00.000Z"; const control = createControl(() => clock);