Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/bb-plugin-agent-checklists/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bb-plugin-agent-checklists",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"engines": {
"node": ">=22.19.0",
Expand Down
27 changes: 20 additions & 7 deletions packages/bb-plugin-agent-checklists/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
});

Expand Down Expand Up @@ -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 () => {
Expand Down
95 changes: 95 additions & 0 deletions packages/bb-plugin-agent-checklists/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,3 +1075,98 @@ describe("Checklists server", () => {
).rejects.toThrow();
});
});

function agentContext(threadId: string): Parameters<TestHost["harness"]["resolveAgentConfiguration"]>[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.",
});
});
});
Loading