From 7737baf554b0aa99c48cd89247d021bfb1ae739d Mon Sep 17 00:00:00 2001 From: Patrick Lee Date: Thu, 15 Oct 2026 08:20:23 -0400 Subject: [PATCH] fix(agent-checklists): expose tools and instructions only to attached threads Resolve the bb.agents.configure selection per thread from the persisted store, so threads without a Checklist get no checklist tools, no tool-registration instructions, and no dynamic instruction. Attach, detach, and replacement apply on the next resolution. Bump to 0.2.1. Fixes #12 Co-Authored-By: Claude Opus 5.5 --- .../bb-plugin-agent-checklists/package.json | 2 +- packages/bb-plugin-agent-checklists/server.ts | 27 ++++-- .../src/server.test.ts | 95 +++++++++++++++++++ 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/bb-plugin-agent-checklists/package.json b/packages/bb-plugin-agent-checklists/package.json index 93ff8ab..259c418 100644 --- a/packages/bb-plugin-agent-checklists/package.json +++ b/packages/bb-plugin-agent-checklists/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-agent-checklists", - "version": "0.2.0", + "version": "0.2.1", "type": "module", "engines": { "node": ">=22.19.0", diff --git a/packages/bb-plugin-agent-checklists/server.ts b/packages/bb-plugin-agent-checklists/server.ts index 9cc6719..e765902 100644 --- a/packages/bb-plugin-agent-checklists/server.ts +++ b/packages/bb-plugin-agent-checklists/server.ts @@ -696,7 +696,11 @@ export default async function plugin(bb: BbPluginApi) { }, parameters: z.object({}).strict(), execute(_input, context) { - return JSON.stringify({ checklist: store.getChecklistForThread(context.threadId) }); + const checklist = store.getChecklistForThread(context.threadId); + if (!checklist) { + return JSON.stringify({ checklist: null, message: "No Checklist is attached to this thread." }); + } + return JSON.stringify({ checklist }); }, }); @@ -725,12 +729,21 @@ export default async function plugin(bb: BbPluginApi) { }, }); - bb.agents.configure(() => ({ - tools: ["agent_checklist_get", "agent_checklist_update"], - skills: [], - instructions: - "When a checklist is attached to this thread, use its tools to read the steps and persist checkbox updates, notes, and evidence as you work.", - })); + // Resolved per thread at every thread.start / turn.submit. A thread without an + // attached Checklist gets no checklist tools, so neither the tools nor their + // registered `instructions` reach the model. The store is read on every + // resolution, so attach, detach, and replacement take effect on the next turn + // without a plugin reload or a cached copy. + bb.agents.configure(({ thread }) => + store.getChecklistForThread(thread.id) + ? { + tools: ["agent_checklist_get", "agent_checklist_update"], + skills: [], + instructions: + "A Checklist is attached to this thread. Use its tools to read the steps and persist checkbox updates, notes, and evidence as you work.", + } + : { tools: [], skills: [] }, + ); bb.events.on("thread.idle", ({ thread }) => { void (async () => { diff --git a/packages/bb-plugin-agent-checklists/src/server.test.ts b/packages/bb-plugin-agent-checklists/src/server.test.ts index 9256ad5..0a56035 100644 --- a/packages/bb-plugin-agent-checklists/src/server.test.ts +++ b/packages/bb-plugin-agent-checklists/src/server.test.ts @@ -1075,3 +1075,98 @@ describe("Checklists server", () => { ).rejects.toThrow(); }); }); + +function agentContext(threadId: string): Parameters[0] { + return { + thread: { id: threadId, title: null, parentThreadId: null, sourceThreadId: null }, + project: { id: "project-1", kind: "standard", name: "Project", gitRemoteUrl: null }, + environment: { + id: "env-1", + name: null, + path: null, + workspaceProvisionType: "personal", + branchName: null, + }, + host: { id: "host-1", name: "Host" }, + provider: { id: "codex", model: "test", capabilities: { supportsNativeUserQuestion: false } }, + origin: { kind: null, pluginId: null }, + }; +} + +async function resolveFor(host: TestHost, threadId: string) { + const resolved = await host.harness.resolveAgentConfiguration(agentContext(threadId)); + const toolInstructions = resolved.tools.map((tool) => tool.instructions ?? "").join("\n"); + return { + toolNames: resolved.tools.map((tool) => tool.name).sort(), + instructions: resolved.instructions, + surface: `${resolved.instructions ?? ""}\n${toolInstructions}`, + }; +} + +describe("Checklists agent activation", () => { + it("gives a thread without a Checklist no checklist tools or instructions", async () => { + const host = await startHost(); + const resolved = await resolveFor(host, "thread-1"); + expect(resolved.toolNames).toEqual([]); + expect(resolved.instructions).toBeNull(); + expect(resolved.surface).not.toMatch(/checklist/i); + }); + + it("gives an attached thread its tools and instructions", async () => { + const host = await startHost(); + await host.harness.callRpc("attach", { threadId: "thread-1", templateId: softwareTemplateId(host) }); + const resolved = await resolveFor(host, "thread-1"); + expect(resolved.toolNames).toEqual(["agent_checklist_get", "agent_checklist_update"]); + expect(resolved.instructions).toContain("Checklist is attached"); + expect(resolved.surface).toContain("Read the checklist before starting work"); + }); + + it("follows attach, detach, and replacement on the next resolution", async () => { + const host = await startHost(); + expect((await resolveFor(host, "thread-1")).toolNames).toEqual([]); + + const first = (await host.harness.callRpc("attach", { + threadId: "thread-1", + templateId: softwareTemplateId(host), + })) as Checklist; + expect((await resolveFor(host, "thread-1")).toolNames).toHaveLength(2); + + await host.harness.callRpc("detach", { checklistId: first.id }); + const detached = await resolveFor(host, "thread-1"); + expect(detached.toolNames).toEqual([]); + expect(detached.surface).not.toMatch(/checklist/i); + + await host.harness.callRpc("attach", { threadId: "thread-1", templateId: researchTemplateId(host) }); + expect((await resolveFor(host, "thread-1")).toolNames).toHaveLength(2); + const read = await host.harness.callAgentTool("agent_checklist_get", {}, { + threadId: "thread-1", + projectId: "project-1", + }); + expect(String(read)).toContain("Research to technical document"); + expect(String(read)).not.toContain("Software delivery"); + }); + + it("resolves each thread independently and survives a reload", async () => { + const host = await startHost(); + await host.harness.callRpc("attach", { threadId: "thread-1", templateId: softwareTemplateId(host) }); + expect((await resolveFor(host, "thread-1")).toolNames).toHaveLength(2); + expect((await resolveFor(host, "thread-2")).toolNames).toEqual([]); + + const reloaded = await host.harness.lifecycle.reload(plugin); + hosts.push(reloaded); + expect((await resolveFor(reloaded, "thread-1")).toolNames).toHaveLength(2); + expect((await resolveFor(reloaded, "thread-2")).toolNames).toEqual([]); + }); + + it("answers a stale tool call in an unattached thread without a Checklist", async () => { + const host = await startHost(); + const read = await host.harness.callAgentTool("agent_checklist_get", {}, { + threadId: "thread-1", + projectId: "project-1", + }); + expect(JSON.parse(String(read))).toEqual({ + checklist: null, + message: "No Checklist is attached to this thread.", + }); + }); +});