From db2c2ff0c8e2e5462ca9c829c08f836192e307fa Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sun, 3 May 2026 16:06:17 +0000 Subject: [PATCH] fix: rebootstrap topic-bound orchestrator prompts --- defaults/devclaw/prompts/orchestrator.md | 8 + lib/dispatch/bootstrap-hook.test.ts | 98 ++++++++- lib/dispatch/bootstrap-hook.ts | 264 ++++++++++++++++------- lib/services/bootstrap.e2e.test.ts | 251 ++++++++------------- lib/setup/templates.ts | 1 + lib/testing/harness.ts | 135 ++++-------- 6 files changed, 418 insertions(+), 339 deletions(-) create mode 100644 defaults/devclaw/prompts/orchestrator.md diff --git a/defaults/devclaw/prompts/orchestrator.md b/defaults/devclaw/prompts/orchestrator.md new file mode 100644 index 00000000..bd78f769 --- /dev/null +++ b/defaults/devclaw/prompts/orchestrator.md @@ -0,0 +1,8 @@ +# Orchestrator Instructions + +You are the DevClaw orchestrator. + +- Use DevClaw tools to manage the workflow. +- Plan, triage, and delegate, but do not implement code changes directly. +- Prefer deterministic tool actions over ad hoc coordination. +- Keep responses concise, clear, and grounded in the current project context. diff --git a/lib/dispatch/bootstrap-hook.test.ts b/lib/dispatch/bootstrap-hook.test.ts index 5b086368..5b49b69c 100644 --- a/lib/dispatch/bootstrap-hook.test.ts +++ b/lib/dispatch/bootstrap-hook.test.ts @@ -4,8 +4,14 @@ */ import { describe, it } from "node:test"; import assert from "node:assert"; -import { parseDevClawSessionKey, loadRoleInstructions } from "./bootstrap-hook.js"; -import { DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; +import { + isMainOrchestratorSession, + parseDevClawSessionKey, + parseMainOrchestratorSessionScope, + loadOrchestratorInstructions, + loadRoleInstructions, +} from "./bootstrap-hook.js"; +import { DEFAULT_ORCHESTRATOR_INSTRUCTIONS, DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; @@ -57,6 +63,56 @@ describe("parseDevClawSessionKey", () => { }); }); +describe("parseMainOrchestratorSessionScope", () => { + it("should parse a real telegram topic session scope", () => { + assert.deepStrictEqual( + parseMainOrchestratorSessionScope("agent:devclaw:telegram:group:-1003581929219:topic:190"), + { channel: "telegram", channelId: "-1003581929219", messageThreadId: "190" }, + ); + }); + + it("should parse a chat-backed orchestrator session without a topic", () => { + assert.deepStrictEqual( + parseMainOrchestratorSessionScope("agent:devclaw:discord:channel:ops-room"), + { channel: "discord", channelId: "ops-room" }, + ); + }); + + it("should reject legacy main and unknown session shapes", () => { + assert.strictEqual(parseMainOrchestratorSessionScope("agent:devclaw:main"), null); + assert.strictEqual(parseMainOrchestratorSessionScope("agent:devclaw:foo:bar"), null); + }); +}); + +describe("isMainOrchestratorSession", () => { + it("should recognize the legacy main session key", () => { + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:main"), true); + }); + + it("should recognize the legacy nested main session key", () => { + assert.strictEqual(isMainOrchestratorSession("agent:main:main"), true); + }); + + it("should recognize real telegram group orchestrator sessions", () => { + assert.strictEqual( + isMainOrchestratorSession("agent:devclaw:telegram:group:-1003581929219:topic:190"), + true, + ); + }); + + it("should reject worker subagent sessions", () => { + assert.strictEqual( + isMainOrchestratorSession("agent:devclaw:subagent:devclaw-developer-medior-cami"), + false, + ); + }); + + it("should reject unknown non-main session shapes", () => { + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:orchestrator"), false); + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:foo:bar"), false); + }); +}); + describe("loadRoleInstructions", () => { it("should load project-specific instructions from devclaw/projects//prompts/", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); @@ -127,3 +183,41 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); }); + +describe("loadOrchestratorInstructions", () => { + it("should prefer project-specific orchestrator prompt over workspace default", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const projectDir = path.join(tmpDir, "devclaw", "projects", "test-project", "prompts"); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, "orchestrator.md"), "project orchestrator"); + await fs.writeFile(path.join(promptsDir, "orchestrator.md"), "workspace orchestrator"); + + const result = await loadOrchestratorInstructions(tmpDir, "test-project"); + assert.strictEqual(result, "project orchestrator"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should fall back to workspace orchestrator prompt", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(promptsDir, "orchestrator.md"), "workspace orchestrator"); + + const result = await loadOrchestratorInstructions(tmpDir, "missing-project"); + assert.strictEqual(result, "workspace orchestrator"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should fall back to package default orchestrator prompt when present", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + + const result = await loadOrchestratorInstructions(tmpDir, "missing-project"); + assert.strictEqual(result, DEFAULT_ORCHESTRATOR_INSTRUCTIONS ?? ""); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); diff --git a/lib/dispatch/bootstrap-hook.ts b/lib/dispatch/bootstrap-hook.ts index f5d47f07..5952a002 100644 --- a/lib/dispatch/bootstrap-hook.ts +++ b/lib/dispatch/bootstrap-hook.ts @@ -1,45 +1,30 @@ /** - * bootstrap-hook.ts — Bootstrap support for DevClaw worker sessions. + * bootstrap-hook.ts — Bootstrap support for DevClaw worker and orchestrator sessions. * * Provides: * 1. agent:bootstrap (internal hook) — replaces the orchestrator's AGENTS.md - * with role-specific instructions so the worker sees its own prompt on - * every turn. Requires hooks.internal.enabled in config. + * with role-specific instructions for workers and injects a single resolved + * orchestrator.md for main orchestrator sessions. * 2. loadRoleInstructions() — loads role-specific prompt files from workspace. - * Used by both the bootstrap hook (persistent per-turn injection) and - * dispatch.ts (extraSystemPrompt fallback for the dispatch turn). + * 3. loadOrchestratorInstructions() — loads the winner-takes-all orchestrator prompt. */ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginContext } from "../context.js"; +import { getProject, readProjects } from "../projects/index.js"; import { getSessionKeyRolePattern } from "../roles/index.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; -import { DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; +import { DEFAULT_ORCHESTRATOR_INSTRUCTIONS, DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; -/** - * Parse a DevClaw subagent session key to extract project name and role. - * - * Session key format (named): `agent:{agentId}:subagent:{projectName}-{role}-{level}-{name}` (name is lowercase) - * Session key format (numeric): `agent:{agentId}:subagent:{projectName}-{role}-{level}-{slotIndex}` - * Session key format (legacy): `agent:{agentId}:subagent:{projectName}-{role}-{level}` - * Examples: - * - `agent:devclaw:subagent:my-project-developer-medior-ada` → { projectName: "my-project", role: "developer" } - * - `agent:devclaw:subagent:my-project-developer-medior-0` → { projectName: "my-project", role: "developer" } - * - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" } (legacy) - * - * Note: projectName may contain hyphens, so we match role from the end. - */ export function parseDevClawSessionKey( sessionKey: string, ): { projectName: string; role: string } | null { const rolePattern = getSessionKeyRolePattern(); - // Named/numeric format: ...-{role}-{level}-{nameOrIndex} const newMatch = sessionKey.match( new RegExp(`:subagent:(.+)-(${rolePattern})-[^-]+-[^-]+$`), ); if (newMatch) return { projectName: newMatch[1], role: newMatch[2] }; - // Legacy format fallback: ...-{role}-{level} (for in-flight sessions during migration) const legacyMatch = sessionKey.match( new RegExp(`:subagent:(.+)-(${rolePattern})-[^-]+$`), ); @@ -47,27 +32,11 @@ export function parseDevClawSessionKey( return null; } -/** - * Result of loading role instructions — includes the source for traceability. - */ -export type RoleInstructionsResult = { +export type PromptInstructionsResult = { content: string; - /** Which file the instructions were loaded from, or null if none found. */ source: string | null; }; -/** - * Load role-specific instructions from workspace. - * Tries project-specific file first, then workspace default, then package default. - * Returns both the content and the source path for logging/traceability. - * - * Resolution order: - * 1. devclaw/projects//prompts/.md (project-specific override) - * 2. projects/roles//.md (old project-specific) - * 3. devclaw/prompts/.md (workspace default) - * 4. projects/roles/default/.md (old default) - * 5. Package default from templates.ts (in-memory fallback) - */ export async function loadRoleInstructions( workspaceDir: string, projectName: string, @@ -78,15 +47,14 @@ export async function loadRoleInstructions( projectName: string, role: string, opts: { withSource: true }, -): Promise; +): Promise; export async function loadRoleInstructions( workspaceDir: string, projectName: string, role: string, opts?: { withSource: true }, -): Promise { +): Promise { const dataDir = path.join(workspaceDir, DATA_DIR); - const candidates = [ path.join(dataDir, "projects", projectName, "prompts", `${role}.md`), path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`), @@ -104,7 +72,6 @@ export async function loadRoleInstructions( } } - // Final fallback: package defaults (in-memory, always available) const packageDefault = DEFAULT_ROLE_INSTRUCTIONS[role]; if (packageDefault) { if (opts?.withSource) return { content: packageDefault, source: "package-default" }; @@ -115,19 +82,119 @@ export async function loadRoleInstructions( return ""; } -/** - * Register the agent:bootstrap hook for DevClaw worker sessions. - * - * Replaces the orchestrator's AGENTS.md with role-specific instructions - * loaded from the workspace. This ensures workers see their own prompt on - * every turn — not just the dispatch turn (where extraSystemPrompt is used). - * - * If role instructions are found, AGENTS.md content is replaced entirely. - * If none are found, AGENTS.md is still stripped to avoid orchestrator bleed. - * - * Requires hooks.internal.enabled in config. If the hook doesn't fire, - * dispatch.ts still passes instructions via extraSystemPrompt (single-turn). - */ +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName?: string, +): Promise; +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName: string | undefined, + opts: { withSource: true }, +): Promise; +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName?: string, + opts?: { withSource: true }, +): Promise { + const dataDir = path.join(workspaceDir, DATA_DIR); + const candidates = [ + ...(projectName + ? [path.join(dataDir, "projects", projectName, "prompts", "orchestrator.md")] + : []), + path.join(dataDir, "prompts", "orchestrator.md"), + ]; + + for (const filePath of candidates) { + try { + const content = await fs.readFile(filePath, "utf-8"); + if (opts?.withSource) return { content, source: filePath }; + return content; + } catch { + /* not found, try next */ + } + } + + if (DEFAULT_ORCHESTRATOR_INSTRUCTIONS) { + if (opts?.withSource) { + return { content: DEFAULT_ORCHESTRATOR_INSTRUCTIONS, source: "package-default" }; + } + return DEFAULT_ORCHESTRATOR_INSTRUCTIONS; + } + + if (opts?.withSource) return { content: "", source: null }; + return ""; +} + +const MAIN_SESSION_PATTERNS = [ + /^agent:[^:]+:main$/, + /^agent:[^:]+:main:main$/, + /^agent:[^:]+:(telegram|whatsapp|discord|slack):(group|dm|channel):[^:]+(?::topic:[^:]+)?$/, +]; + +export function isMainOrchestratorSession(sessionKey: string): boolean { + return MAIN_SESSION_PATTERNS.some((pattern) => pattern.test(sessionKey)); +} + +export type OrchestratorSessionScope = { + channel: string; + channelId: string; + messageThreadId?: string; +}; + +export function parseMainOrchestratorSessionScope( + sessionKey: string, +): OrchestratorSessionScope | null { + const match = sessionKey.match( + /^agent:[^:]+:(telegram|whatsapp|discord|slack):(group|dm|channel):([^:]+)(?::topic:([^:]+))?$/, + ); + if (!match) return null; + return { + channel: match[1], + channelId: match[3], + ...(match[4] ? { messageThreadId: match[4] } : {}), + }; +} + +async function resolveProjectNameForBootstrap( + workspaceDir: string, + context: Record, + sessionKey?: string, +): Promise { + const sessionScope = sessionKey ? parseMainOrchestratorSessionScope(sessionKey) : null; + const channelId = + (typeof context.channelId === "string" && context.channelId.trim() ? context.channelId : undefined) ?? + (typeof context.conversationId === "string" && context.conversationId.trim() + ? context.conversationId + : undefined) ?? + (typeof context.peerId === "string" && context.peerId.trim() ? context.peerId : undefined) ?? + sessionScope?.channelId; + + if (!channelId) return undefined; + + const messageThreadId = + typeof context.messageThreadId === "number" || typeof context.messageThreadId === "string" + ? context.messageThreadId + : typeof context.threadId === "number" || typeof context.threadId === "string" + ? context.threadId + : sessionScope?.messageThreadId; + + try { + const data = await readProjects(workspaceDir); + const project = getProject(data, { + channelId, + channel: + typeof context.channel === "string" && context.channel.trim() + ? context.channel + : sessionScope?.channel ?? "telegram", + accountId: typeof context.accountId === "string" ? context.accountId : undefined, + messageThreadId, + }); + return project?.name; + } catch { + return undefined; + } +} + export function registerBootstrapHook(api: OpenClawPluginApi, ctx: PluginContext): void { api.registerHook( "agent:bootstrap", @@ -136,10 +203,18 @@ export function registerBootstrapHook(api: OpenClawPluginApi, ctx: PluginContext if (!sessionKey) return; const parsed = parseDevClawSessionKey(sessionKey); - if (!parsed) return; + const isOrchestrator = isMainOrchestratorSession(sessionKey); + if (!parsed && !isOrchestrator) return; const context = event.context as { workspaceDir?: string; + channelId?: string; + conversationId?: string; + peerId?: string; + channel?: string; + accountId?: string; + threadId?: number | string; + messageThreadId?: number | string; bootstrapFiles?: Array<{ name: string; path: string; @@ -154,37 +229,74 @@ export function registerBootstrapHook(api: OpenClawPluginApi, ctx: PluginContext const agentsEntry = bootstrapFiles.find((f) => f.name === "AGENTS.md"); if (!agentsEntry) return; - // Load role instructions from workspace (project-specific → default fallback) const workspaceDir = context.workspaceDir; if (!workspaceDir) { - agentsEntry.content = ""; - agentsEntry.missing = true; - ctx.logger.info( - `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no workspaceDir)`, + if (parsed) { + agentsEntry.content = ""; + agentsEntry.missing = true; + ctx.logger.info( + `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no workspaceDir)`, + ); + } + return; + } + + if (parsed) { + const { content, source } = await loadRoleInstructions( + workspaceDir, + parsed.projectName, + parsed.role, + { withSource: true }, ); + + if (content.trim()) { + agentsEntry.content = content; + agentsEntry.missing = false; + ctx.logger.info( + `agent:bootstrap: injected ${parsed.role} instructions for "${parsed.projectName}" from ${source}`, + ); + } else { + agentsEntry.content = ""; + agentsEntry.missing = true; + ctx.logger.info( + `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no role instructions found)`, + ); + } return; } - const { content, source } = await loadRoleInstructions( + const projectName = await resolveProjectNameForBootstrap( workspaceDir, - parsed.projectName, - parsed.role, - { withSource: true }, + context as Record, + sessionKey, ); + const { content, source } = await loadOrchestratorInstructions(workspaceDir, projectName, { + withSource: true, + }); + if (!content.trim()) return; - if (content.trim()) { - agentsEntry.content = content; - agentsEntry.missing = false; - ctx.logger.info( - `agent:bootstrap: injected ${parsed.role} instructions for "${parsed.projectName}" from ${source}`, - ); + const existing = bootstrapFiles.find((f) => f.name === "orchestrator.md"); + if (existing) { + existing.content = content; + existing.missing = false; } else { - agentsEntry.content = ""; - agentsEntry.missing = true; - ctx.logger.info( - `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no role instructions found)`, - ); + bootstrapFiles.push({ + name: "orchestrator.md", + path: path.join(workspaceDir, "orchestrator.md"), + content, + missing: false, + }); } + + const synthetic = bootstrapFiles.find((f) => f.name === "DEVCLAW_ORCHESTRATOR_PROMPT.md"); + if (synthetic) { + synthetic.content = ""; + synthetic.missing = true; + } + + ctx.logger.info( + `agent:bootstrap: injected orchestrator instructions${projectName ? ` for "${projectName}"` : ""} from ${source}`, + ); }, { name: "devclaw-bootstrap-role-instructions", diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts index 23127f5d..e832cd20 100644 --- a/lib/services/bootstrap.e2e.test.ts +++ b/lib/services/bootstrap.e2e.test.ts @@ -1,13 +1,10 @@ /** - * E2E bootstrap tests — verifies role instructions reach workers via extraSystemPrompt: - * dispatchTask() → loadRoleInstructions() → gateway agent call includes extraSystemPrompt - * - * Also tests that the agent:bootstrap hook strips AGENTS.md from worker sessions. - * - * Run: npx tsx --test lib/services/bootstrap.e2e.test.ts + * E2E bootstrap tests. */ import { describe, it, afterEach } from "node:test"; import assert from "node:assert"; +import fs from "node:fs/promises"; +import path from "node:path"; import { createTestHarness, type TestHarness } from "../testing/index.js"; import { dispatchTask } from "../dispatch/index.js"; @@ -21,8 +18,6 @@ describe("E2E bootstrap — extraSystemPrompt injection", () => { it("should inject project-specific instructions via extraSystemPrompt", async () => { h = await createTestHarness({ projectName: "my-app" }); h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] }); - - // Write both default and project-specific prompts await h.writePrompt("developer", "# Default Developer\nGeneric instructions."); await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app"); @@ -42,192 +37,116 @@ describe("E2E bootstrap — extraSystemPrompt injection", () => { runCommand: h.runCommand, }); - // Verify extraSystemPrompt in the gateway agent call const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1, `Expected 1 extraSystemPrompt, got ${prompts.length}`); - assert.ok(prompts[0].includes("My App Developer"), `Got: ${prompts[0]}`); - assert.ok(prompts[0].includes("Use React")); + assert.strictEqual(prompts.length, 1); + assert.ok(prompts[0].includes("My App Developer")); assert.ok(!prompts[0].includes("Generic instructions")); }); +}); - it("should fall back to default instructions when no project override exists", async () => { - h = await createTestHarness({ projectName: "other-app" }); - h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] }); - - // Only write default prompt — no project-specific - await h.writePrompt("developer", "# Default Developer\nFollow coding standards."); - - await dispatchTask({ - workspaceDir: h.workspaceDir, - agentId: "main", - project: h.project, - issueId: 2, - issueTitle: "Fix bug", - issueDescription: "", - issueUrl: "https://example.com/issues/2", - role: "developer", - level: "junior", - fromLabel: "To Do", - toLabel: "Doing", - provider: h.provider, - runCommand: h.runCommand, - }); +describe("E2E bootstrap — agent:bootstrap hook", () => { + let h: TestHarness; - const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1); - assert.ok(prompts[0].includes("Default Developer")); - assert.ok(prompts[0].includes("Follow coding standards")); + afterEach(async () => { + if (h) await h.cleanup(); }); - it("should inject scaffolded default instructions when no overrides exist", async () => { - h = await createTestHarness({ projectName: "bare-app" }); - h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] }); - - // Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults - - await dispatchTask({ - workspaceDir: h.workspaceDir, - agentId: "main", - project: h.project, - issueId: 3, - issueTitle: "Chore", - issueDescription: "", - issueUrl: "https://example.com/issues/3", - role: "developer", - level: "medior", - fromLabel: "To Do", - toLabel: "Doing", - provider: h.provider, - runCommand: h.runCommand, - }); - - const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1, "Default scaffolded prompt should be injected"); - assert.match(prompts[0], /Developer/i); + it("should keep worker bootstrap scoped to AGENTS.md only", async () => { + h = await createTestHarness({ projectName: "my-app" }); + const result = await h.simulateBootstrap("agent:main:subagent:my-app-developer-medior-Ada"); + assert.ok(result.agentsContent); + assert.ok(!result.agentsContent?.includes("Orchestrator instructions")); + assert.ok(!result.bootstrapFileNames.includes("orchestrator.md")); }); - it("should resolve tester instructions independently from developer", async () => { - h = await createTestHarness({ projectName: "multi-role" }); - h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] }); - - // Write project-specific for developer, default for tester - await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role"); - await h.writePrompt("tester", "# Default Tester\nRun integration tests."); + it("should inject the project-specific orchestrator prompt into a real chat-backed orchestrator session", async () => { + h = await createTestHarness({ projectName: "my-app", channelId: "-1003581929219", messageThreadId: 190 }); + await h.writePrompt("orchestrator", "# My App Orchestrator\nUse the app-specific workflow.", "my-app"); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); - // Dispatch as tester - await dispatchTask({ - workspaceDir: h.workspaceDir, - agentId: "main", - project: h.project, - issueId: 4, - issueTitle: "Test thing", - issueDescription: "", - issueUrl: "https://example.com/issues/4", - role: "tester", - level: "medior", - fromLabel: "To Test", - toLabel: "Testing", - provider: h.provider, - runCommand: h.runCommand, + const result = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003581929219:topic:190", { + channelId: "-1003581929219", + channel: "telegram", + messageThreadId: 190, }); - const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1); - assert.ok(prompts[0].includes("Default Tester")); - assert.ok(!prompts[0].includes("Dev for multi-role")); + assert.strictEqual(result.agentsMdStripped, false); + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("My App Orchestrator")); + assert.ok(!result.orchestratorContent?.includes("Generic workflow")); }); - it("should handle project names with hyphens correctly", async () => { - h = await createTestHarness({ projectName: "my-cool-project" }); - h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] }); - - await h.writePrompt( - "developer", - "# Hyphenated Project\nThis project has hyphens in the name.", - "my-cool-project", - ); + it("should resolve the project-specific orchestrator prompt from the real session key when bootstrap context omits chat scope", async () => { + h = await createTestHarness({ projectName: "firstlight", channelId: "-1003746138337", messageThreadId: 2270 }); + await h.writePrompt("orchestrator", "ticks\nfire stitcher", "firstlight"); + await h.writePrompt("orchestrator", "wasps\nfire hullcracker"); - await dispatchTask({ - workspaceDir: h.workspaceDir, - agentId: "main", - project: h.project, - issueId: 5, - issueTitle: "Hyphen test", - issueDescription: "", - issueUrl: "https://example.com/issues/5", - role: "developer", - level: "senior", - fromLabel: "To Do", - toLabel: "Doing", - provider: h.provider, - runCommand: h.runCommand, + const result = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:2270", { + channel: "telegram", }); - const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1); - assert.ok(prompts[0].includes("Hyphenated Project")); + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("ticks")); + assert.ok(result.orchestratorContent?.includes("fire stitcher")); + assert.ok(!result.orchestratorContent?.includes("wasps")); }); - it("should resolve architect instructions with project override", async () => { - h = await createTestHarness({ projectName: "arch-proj" }); - h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["Planning"] }); + it("should replace stale orchestrator.md content across repeated fresh bootstrap runs on the same topic key", async () => { + h = await createTestHarness({ projectName: "firstlight", channelId: "-1003746138337", messageThreadId: 1 }); + const projectPrompt = path.join(h.workspaceDir, "devclaw", "projects", "firstlight", "prompts", "orchestrator.md"); + const workspacePrompt = path.join(h.workspaceDir, "devclaw", "prompts", "orchestrator.md"); - await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines."); - await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj"); + await h.writePrompt("orchestrator", "wasps\nfire hullcracker"); + await h.writePrompt("orchestrator", "ticks\nfire stitcher", "firstlight"); - await dispatchTask({ - workspaceDir: h.workspaceDir, - agentId: "main", - project: h.project, - issueId: 6, - issueTitle: "Design API", - issueDescription: "", - issueUrl: "https://example.com/issues/6", - role: "architect", - level: "senior", - fromLabel: "Planning", - toLabel: "Planning", - provider: h.provider, - runCommand: h.runCommand, + const first = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:1", { + channel: "telegram", }); + assert.ok(first.orchestratorContent?.includes("ticks")); + assert.ok(first.orchestratorContent?.includes("fire stitcher")); - const prompts = h.commands.extraSystemPrompts(); - assert.strictEqual(prompts.length, 1); - assert.ok(prompts[0].includes("Arch Proj Architect")); - assert.ok(prompts[0].includes("event-driven")); - assert.ok(!prompts[0].includes("General design guidelines")); - }); -}); - -describe("E2E bootstrap — agent:bootstrap hook (AGENTS.md stripping)", () => { - let h: TestHarness; - - afterEach(async () => { - if (h) await h.cleanup(); - }); - - it("should strip AGENTS.md for DevClaw worker sessions", async () => { - h = await createTestHarness({ projectName: "my-app" }); - - const result = await h.simulateBootstrap( - "agent:main:subagent:my-app-developer-medior-Ada", - ); - assert.strictEqual(result.agentsMdStripped, true); + await fs.rm(projectPrompt); + const second = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:1", { + channel: "telegram", + bootstrapFiles: first.bootstrapFiles, + }); + assert.ok(second.orchestratorContent?.includes("wasps")); + assert.ok(second.orchestratorContent?.includes("fire hullcracker")); + assert.ok(!second.orchestratorContent?.includes("ticks")); + assert.ok(!second.orchestratorContent?.includes("fire stitcher")); + + await fs.writeFile(projectPrompt, "ticks v2\nfire needlecaster", "utf-8"); + const third = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:1", { + channel: "telegram", + bootstrapFiles: second.bootstrapFiles, + }); + assert.ok(third.orchestratorContent?.includes("ticks v2")); + assert.ok(third.orchestratorContent?.includes("fire needlecaster")); + assert.ok(!third.orchestratorContent?.includes("wasps")); + assert.ok(!third.orchestratorContent?.includes("fire hullcracker")); + + await fs.writeFile(workspacePrompt, "wasps v2\nfire emberhammer", "utf-8"); + await fs.rm(projectPrompt); + const fourth = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:1", { + channel: "telegram", + bootstrapFiles: third.bootstrapFiles, + }); + assert.ok(fourth.orchestratorContent?.includes("wasps v2")); + assert.ok(fourth.orchestratorContent?.includes("fire emberhammer")); + assert.ok(!fourth.orchestratorContent?.includes("ticks v2")); + assert.ok(!fourth.orchestratorContent?.includes("fire needlecaster")); }); - it("should NOT strip AGENTS.md for non-DevClaw sessions", async () => { + it("should NOT inject orchestrator.md for non-main, non-worker sessions", async () => { h = await createTestHarness(); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); - const result = await h.simulateBootstrap("agent:main:orchestrator"); - assert.strictEqual(result.agentsMdStripped, false); - }); - - it("should NOT strip AGENTS.md for unknown roles", async () => { - h = await createTestHarness({ projectName: "custom-app" }); + const result = await h.simulateBootstrap("agent:main:orchestrator", { + channelId: h.channelId, + channel: "telegram", + }); - const result = await h.simulateBootstrap( - "agent:main:subagent:custom-app-investigator-medior", - ); assert.strictEqual(result.agentsMdStripped, false); + assert.ok(!result.bootstrapFileNames.includes("orchestrator.md")); }); }); diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 3740bb5b..82e46bdb 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -49,6 +49,7 @@ const DEFAULT_DEV_INSTRUCTIONS = loadDefault("devclaw/prompts/developer.md"); const DEFAULT_QA_INSTRUCTIONS = loadDefault("devclaw/prompts/tester.md"); const DEFAULT_ARCHITECT_INSTRUCTIONS = loadDefault("devclaw/prompts/architect.md"); const DEFAULT_REVIEWER_INSTRUCTIONS = loadDefault("devclaw/prompts/reviewer.md"); +export const DEFAULT_ORCHESTRATOR_INSTRUCTIONS = loadDefault("devclaw/prompts/orchestrator.md"); /** Default role instructions indexed by role ID. Used by project scaffolding. */ export const DEFAULT_ROLE_INSTRUCTIONS: Record = { diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts index 8f58742c..aa083d02 100644 --- a/lib/testing/harness.ts +++ b/lib/testing/harness.ts @@ -1,10 +1,6 @@ /** * Test harness — scaffolds a temporary workspace with projects.json, * installs a mock runCommand, and provides helpers for E2E pipeline tests. - * - * Usage: - * const h = await createTestHarness({ ... }); - * try { ... } finally { await h.cleanup(); } */ import fs from "node:fs/promises"; import path from "node:path"; @@ -16,46 +12,37 @@ import { TestProvider } from "./test-provider.js"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginContext } from "../context.js"; -// --------------------------------------------------------------------------- -// Bootstrap result type — represents the agent:bootstrap hook outcome -// --------------------------------------------------------------------------- +export type BootstrapFile = { + name: string; + path: string; + content?: string; + missing: boolean; +}; export type BootstrapResult = { - /** Whether AGENTS.md was stripped from bootstrap files. */ agentsMdStripped: boolean; + bootstrapFileNames: string[]; + orchestratorContent?: string; + agentsContent?: string; + bootstrapFiles: BootstrapFile[]; }; -// --------------------------------------------------------------------------- -// Command interceptor -// --------------------------------------------------------------------------- - export type CapturedCommand = { argv: string[]; opts: { timeoutMs: number; cwd?: string }; - /** Extracted from gateway `agent` call params, if applicable. */ taskMessage?: string; - /** Extracted from gateway `agent` call params, if applicable. */ extraSystemPrompt?: string; - /** Extracted from gateway `agent` call params, if applicable. */ agentModel?: string; - /** Extracted from gateway `sessions.patch` params, if applicable. */ sessionPatch?: { key: string; model: string; label?: string }; }; export type CommandInterceptor = { - /** All captured commands, in order. */ commands: CapturedCommand[]; - /** Filter commands by first argv element. */ commandsFor(cmd: string): CapturedCommand[]; - /** Get all task messages sent via `openclaw gateway call agent`. */ taskMessages(): string[]; - /** Get all extraSystemPrompt values sent via `openclaw gateway call agent`. */ extraSystemPrompts(): string[]; - /** Get all agent models sent via `openclaw gateway call agent`. */ agentModels(): string[]; - /** Get all session patches. */ sessionPatches(): Array<{ key: string; model: string; label?: string }>; - /** Reset captured commands. */ reset(): void; }; @@ -75,7 +62,6 @@ function createCommandInterceptor(): { const captured: CapturedCommand = { argv, opts }; - // Parse gateway agent calls to extract task message if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") { const rpcMethod = argv[3]; const paramsIdx = argv.indexOf("--params"); @@ -84,22 +70,17 @@ function createCommandInterceptor(): { const params = JSON.parse(argv[paramsIdx + 1]); if (rpcMethod === "agent" && params.message) { captured.taskMessage = params.message; - if (params.extraSystemPrompt) { - captured.extraSystemPrompt = params.extraSystemPrompt; - } - if (params.model) { - captured.agentModel = params.model; - } + if (params.extraSystemPrompt) captured.extraSystemPrompt = params.extraSystemPrompt; + if (params.model) captured.agentModel = params.model; } if (rpcMethod === "sessions.patch") { captured.sessionPatch = { key: params.key, model: params.model, label: params.label }; } - } catch { /* ignore parse errors */ } + } catch {} } } commands.push(captured); - return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const }; }; @@ -109,24 +90,16 @@ function createCommandInterceptor(): { return commands.filter((c) => c.argv[0] === cmd); }, taskMessages() { - return commands - .filter((c) => c.taskMessage !== undefined) - .map((c) => c.taskMessage!); + return commands.filter((c) => c.taskMessage !== undefined).map((c) => c.taskMessage!); }, extraSystemPrompts() { - return commands - .filter((c) => c.extraSystemPrompt !== undefined) - .map((c) => c.extraSystemPrompt!); + return commands.filter((c) => c.extraSystemPrompt !== undefined).map((c) => c.extraSystemPrompt!); }, agentModels() { - return commands - .filter((c) => c.agentModel !== undefined) - .map((c) => c.agentModel!); + return commands.filter((c) => c.agentModel !== undefined).map((c) => c.agentModel!); }, sessionPatches() { - return commands - .filter((c) => c.sessionPatch !== undefined) - .map((c) => c.sessionPatch!); + return commands.filter((c) => c.sessionPatch !== undefined).map((c) => c.sessionPatch!); }, reset() { commands.length = 0; @@ -136,59 +109,29 @@ function createCommandInterceptor(): { return { interceptor, handler }; } -// --------------------------------------------------------------------------- -// Test harness -// --------------------------------------------------------------------------- - export type TestHarness = { - /** Temporary workspace directory. */ workspaceDir: string; - /** In-memory issue provider. */ provider: TestProvider; - /** Command interceptor — captures all runCommand calls. */ commands: CommandInterceptor; - /** Mock runCommand function for passing to functions that require it. */ runCommand: import("../context.js").RunCommand; - /** The project channel ID used for test data. */ channelId: string; - /** The project data. */ project: Project; - /** Workflow config. */ workflow: WorkflowConfig; - /** Write updated projects data to disk. */ writeProjects(data: ProjectsData): Promise; - /** Read current projects data from disk. */ readProjects(): Promise; - /** - * Write a role prompt file to the workspace. - * @param role - Role name (e.g. "developer", "tester") - * @param content - Prompt file content - * @param projectName - If provided, writes project-specific prompt; otherwise writes default. - */ writePrompt(role: string, content: string, projectName?: string): Promise; - /** - * Simulate the agent:bootstrap hook firing for a session key. - * Tests that AGENTS.md is stripped from bootstrap files for DevClaw workers. - */ - simulateBootstrap(sessionKey: string): Promise; - /** Clean up temp directory. */ + simulateBootstrap(sessionKey: string, contextOverrides?: Record): Promise; cleanup(): Promise; }; export type HarnessOptions = { - /** Project name (default: "test-project"). */ projectName?: string; - /** Channel ID (default: "-1234567890"). */ channelId?: string; - /** Repo path (default: "/tmp/test-repo"). */ + messageThreadId?: number; repo?: string; - /** Base branch (default: "main"). */ baseBranch?: string; - /** Workflow config (default: DEFAULT_WORKFLOW). */ workflow?: WorkflowConfig; - /** Initial worker state overrides (level + slot fields). */ workers?: Record; - /** Additional projects to seed. */ extraProjects?: Record; }; @@ -196,6 +139,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise ({ levels: {} }); const defaultWorkers: Record = { developer: emptyRW(), @@ -218,7 +160,6 @@ export async function createTestHarness(opts?: HarnessOptions): Promise = {}, + ) { let internalHookCb: ((event: any) => Promise) | null = null; const mockApi = { registerHook(_name: string, cb: (event: any) => Promise) { @@ -299,14 +244,10 @@ export async function createTestHarness(opts?: HarnessOptions): Promise Promise) | null; if (hookCb) { await hookCb({ sessionKey, - context: { bootstrapFiles }, + context: { workspaceDir, bootstrapFiles, ...contextOverrides }, }); } + const orchestratorEntry = bootstrapFiles.find((f) => f.name === "orchestrator.md"); return { agentsMdStripped: bootstrapFiles[0].missing === true && bootstrapFiles[0].content === "", + bootstrapFileNames: bootstrapFiles.map((f) => f.name), + orchestratorContent: orchestratorEntry?.content, + agentsContent: bootstrapFiles[0]?.content, + bootstrapFiles, }; }, async cleanup() {