diff --git a/apps/server/src/internal/tool-calls.ts b/apps/server/src/internal/tool-calls.ts index e02c3cb03..0cf3feebb 100644 --- a/apps/server/src/internal/tool-calls.ts +++ b/apps/server/src/internal/tool-calls.ts @@ -7,6 +7,10 @@ import type { Hono } from "hono"; import type { AppDeps } from "../types.js"; import { ApiError } from "../errors.js"; import { requireThreadEnvironment } from "../services/lib/entity-lookup.js"; +import { + handleUpdateEnvironmentDirectoryToolCall, + UPDATE_ENVIRONMENT_DIRECTORY_TOOL_NAME, +} from "../services/threads/thread-environment-directory.js"; import { requireAuthenticatedDaemonSession } from "./session-state.js"; export function registerInternalToolCallRoutes(app: Hono, deps: AppDeps): void { @@ -23,7 +27,10 @@ export function registerInternalToolCallRoutes(app: Hono, deps: AppDeps): void { db: deps.db, sessionId: payload.sessionId, }); - const { environment } = requireThreadEnvironment(deps.db, payload.threadId); + const { environment, thread } = requireThreadEnvironment( + deps.db, + payload.threadId, + ); if (environment.hostId !== session.hostId) { throw new ApiError( 403, @@ -32,6 +39,16 @@ export function registerInternalToolCallRoutes(app: Hono, deps: AppDeps): void { ); } + if (payload.tool === UPDATE_ENVIRONMENT_DIRECTORY_TOOL_NAME) { + return context.json( + await handleUpdateEnvironmentDirectoryToolCall(deps, { + currentEnvironment: environment, + input: payload.arguments, + thread, + }), + ); + } + return context.json({ success: false, contentItems: [ diff --git a/apps/server/src/services/threads/thread-environment-directory.ts b/apps/server/src/services/threads/thread-environment-directory.ts new file mode 100644 index 000000000..b73d79783 --- /dev/null +++ b/apps/server/src/services/threads/thread-environment-directory.ts @@ -0,0 +1,348 @@ +import { z } from "zod"; +import { + createEnvironment, + createEnvironmentProvisioningId, + createEventId, + findEnvironmentByHostPath, + getEnvironment, + getThread, + updateThread, +} from "@bb/db"; +import { threadScope } from "@bb/domain"; +import type { + DynamicTool, + Environment, + Thread, + ToolCallResponse, +} from "@bb/domain"; +import type { AppDeps } from "../../types.js"; +import { runLiveHostCommand } from "../hosts/live-command.js"; +import { appendThreadEventInTransaction } from "./thread-events.js"; +import { buildEnvironmentProvisionCommand } from "./thread-create-helpers.js"; + +export const UPDATE_ENVIRONMENT_DIRECTORY_TOOL_NAME = + "update_environment_directory"; + +const UPDATE_ENVIRONMENT_DIRECTORY_TIMEOUT_MS = 5 * 60 * 1000; + +const updateEnvironmentDirectoryInputSchema = z + .object({ + path: z.string().trim().min(1), + }) + .strict(); + +export const UPDATE_ENVIRONMENT_DIRECTORY_TOOL: DynamicTool = { + name: UPDATE_ENVIRONMENT_DIRECTORY_TOOL_NAME, + description: + "Move this bb thread to a different working directory for subsequent turns. Use this when the user asks to switch to a new checkout, worktree, or local directory. The path must be an absolute existing directory on the current host. The tool reuses any existing bb environment for that host/path, otherwise it creates an unmanaged environment after validating the path. After a successful switch, stop the current turn because the running provider cwd will not change until the next turn.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: + "Absolute path to an existing directory on the current host.", + }, + }, + required: ["path"], + additionalProperties: false, + }, +}; + +interface HandleUpdateEnvironmentDirectoryToolCallArgs { + currentEnvironment: Environment; + input: unknown; + thread: Thread; +} + +type ReadyEnvironment = Environment & { path: string; status: "ready" }; + +type AttachEnvironmentResult = + | { kind: "attached"; changed: boolean } + | { kind: "environment_changed" } + | { kind: "thread_unavailable"; message: string }; + +function toolCallTextResponse( + success: boolean, + text: string, +): ToolCallResponse { + return { + success, + contentItems: [{ type: "inputText", text }], + }; +} + +function toolCallFailure(text: string): ToolCallResponse { + return toolCallTextResponse(false, text); +} + +function toolCallSuccess(text: string): ToolCallResponse { + return toolCallTextResponse(true, text); +} + +function normalizeDirectoryPath(path: string): string { + const trimmed = path.trim(); + if (trimmed === "/") { + return trimmed; + } + return trimmed.replace(/\/+$/u, ""); +} + +function validateDirectoryPath(path: string): string | null { + if (!path.startsWith("/")) { + return "Path must be an absolute path on the current host."; + } + if (path === "/") { + return "Path must name a project directory, not the filesystem root."; + } + if (path.includes("\0")) { + return "Path must not contain NUL bytes."; + } + return null; +} + +function threadWritableFailure(thread: Thread): string | null { + if (thread.deletedAt !== null) { + return "Cannot update the environment directory for a deleted thread."; + } + if (thread.archivedAt !== null) { + return "Cannot update the environment directory for an archived thread."; + } + return null; +} + +function readyEnvironmentFailure(environment: Environment): string | null { + if (environment.status !== "ready") { + return `Environment at this path is ${environment.status}, not ready.`; + } + if (!environment.path) { + return "Environment at this path does not have a resolved directory."; + } + return null; +} + +function asReadyEnvironment(environment: Environment): ReadyEnvironment | null { + if (environment.status !== "ready" || !environment.path) { + return null; + } + return { + ...environment, + path: environment.path, + status: environment.status, + }; +} + +function successMessage(path: string): string { + return `Environment directory updated to ${path}. This applies to future turns; stop work in this turn so the next turn can run from the updated directory.`; +} + +function attachReadyEnvironment( + deps: Pick, + args: { + currentEnvironment: Environment; + createdEnvironment: boolean; + targetEnvironment: ReadyEnvironment; + thread: Thread; + }, +): AttachEnvironmentResult { + const result = deps.db.transaction( + (tx): AttachEnvironmentResult => { + const latestThread = getThread(tx, args.thread.id); + if (!latestThread || latestThread.deletedAt !== null) { + return { + kind: "thread_unavailable", + message: "Thread no longer exists.", + }; + } + + const writableFailure = threadWritableFailure(latestThread); + if (writableFailure) { + return { kind: "thread_unavailable", message: writableFailure }; + } + + if (latestThread.environmentId === args.targetEnvironment.id) { + return { kind: "attached", changed: false }; + } + + if (latestThread.environmentId !== args.currentEnvironment.id) { + return { kind: "environment_changed" }; + } + + updateThread(tx, deps.hub, latestThread.id, { + environmentId: args.targetEnvironment.id, + }); + appendThreadEventInTransaction(tx, { + threadId: latestThread.id, + environmentId: args.targetEnvironment.id, + type: "system/operation", + scope: threadScope(), + data: { + operation: "environment_directory_update", + operationId: createEventId(), + status: "completed", + message: `Updated environment directory to ${args.targetEnvironment.path}`, + metadata: { + createdEnvironment: args.createdEnvironment, + previousEnvironmentId: args.currentEnvironment.id, + previousPath: args.currentEnvironment.path, + nextEnvironmentId: args.targetEnvironment.id, + nextPath: args.targetEnvironment.path, + workspaceProvisionType: + args.targetEnvironment.workspaceProvisionType, + }, + }, + }); + return { kind: "attached", changed: true }; + }, + { behavior: "immediate" }, + ); + + if (result.kind === "attached" && result.changed) { + deps.hub.notifyThread(args.thread.id, ["events-appended"], { + eventTypes: ["system/operation"], + }); + } + + return result; +} + +async function provisionUnmanagedEnvironmentForPath( + deps: AppDeps, + args: { + currentEnvironment: Environment; + path: string; + thread: Thread; + }, +): Promise { + const environment = createEnvironment(deps.db, deps.hub, { + projectId: args.thread.projectId, + hostId: args.currentEnvironment.hostId, + workspaceProvisionType: "unmanaged", + managed: false, + status: "provisioning", + }); + const command = buildEnvironmentProvisionCommand({ + workspaceProvisionType: "unmanaged", + environmentId: environment.id, + hostId: args.currentEnvironment.hostId, + initiator: { + threadId: args.thread.id, + provisioningId: createEnvironmentProvisioningId(), + }, + path: args.path, + }); + + try { + await runLiveHostCommand(deps, { + hostId: args.currentEnvironment.hostId, + command, + timeoutMs: UPDATE_ENVIRONMENT_DIRECTORY_TIMEOUT_MS, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return toolCallFailure( + `Could not update environment directory to ${args.path}: ${message}`, + ); + } + + const readyEnvironment = getEnvironment(deps.db, environment.id); + if (!readyEnvironment) { + return toolCallFailure("Prepared environment no longer exists."); + } + const failure = readyEnvironmentFailure(readyEnvironment); + if (failure) { + return toolCallFailure(failure); + } + const ready = asReadyEnvironment(readyEnvironment); + if (!ready) { + return toolCallFailure("Prepared environment is not ready."); + } + return ready; +} + +export async function handleUpdateEnvironmentDirectoryToolCall( + deps: AppDeps, + args: HandleUpdateEnvironmentDirectoryToolCallArgs, +): Promise { + const input = updateEnvironmentDirectoryInputSchema.safeParse(args.input); + if (!input.success) { + return toolCallFailure( + "Invalid arguments. Provide an object with an absolute path string.", + ); + } + + const normalizedPath = normalizeDirectoryPath(input.data.path); + const pathFailure = validateDirectoryPath(normalizedPath); + if (pathFailure) { + return toolCallFailure(pathFailure); + } + + const writableFailure = threadWritableFailure(args.thread); + if (writableFailure) { + return toolCallFailure(writableFailure); + } + + if (args.currentEnvironment.path === normalizedPath) { + return toolCallSuccess( + `This thread is already using ${normalizedPath} as its environment directory.`, + ); + } + + const existingEnvironment = findEnvironmentByHostPath( + deps.db, + args.currentEnvironment.hostId, + normalizedPath, + ); + let createdEnvironment = false; + let targetEnvironment: ReadyEnvironment; + + if (existingEnvironment) { + if (existingEnvironment.projectId !== args.thread.projectId) { + return toolCallFailure( + "An environment for this host/path already exists on a different project.", + ); + } + const failure = readyEnvironmentFailure(existingEnvironment); + if (failure) { + return toolCallFailure(failure); + } + const ready = asReadyEnvironment(existingEnvironment); + if (!ready) { + return toolCallFailure("Environment at this path is not ready."); + } + targetEnvironment = ready; + } else { + const provisionedEnvironment = await provisionUnmanagedEnvironmentForPath( + deps, + { + currentEnvironment: args.currentEnvironment, + path: normalizedPath, + thread: args.thread, + }, + ); + + if ("success" in provisionedEnvironment) { + return provisionedEnvironment; + } + targetEnvironment = provisionedEnvironment; + createdEnvironment = true; + } + + const attachResult = attachReadyEnvironment(deps, { + currentEnvironment: args.currentEnvironment, + createdEnvironment, + targetEnvironment, + thread: args.thread, + }); + + switch (attachResult.kind) { + case "attached": + return toolCallSuccess(successMessage(targetEnvironment.path)); + case "environment_changed": + return toolCallFailure( + "Thread environment changed while preparing the new directory. Try again with the desired path.", + ); + case "thread_unavailable": + return toolCallFailure(attachResult.message); + } +} diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts index 4a4d5f043..a6a497b8a 100644 --- a/apps/server/src/services/threads/thread-runtime-config.ts +++ b/apps/server/src/services/threads/thread-runtime-config.ts @@ -23,6 +23,7 @@ import { resolveExistingThreadExecutionPlan, } from "./thread-execution-plan.js"; import { resolveInjectedSkillSources } from "../skills/injected-skills.js"; +import { UPDATE_ENVIRONMENT_DIRECTORY_TOOL } from "./thread-environment-directory.js"; export { getSupportedReasoningLevelsForProvider } from "./thread-reasoning-policy.js"; const STANDARD_AGENT_INSTRUCTIONS = renderTemplate( @@ -124,7 +125,7 @@ export async function resolveThreadRuntimeCommandConfig( }); return { - dynamicTools: [], + dynamicTools: [UPDATE_ENVIRONMENT_DIRECTORY_TOOL], injectedSkillSources, instructionMode: "append", instructions: STANDARD_AGENT_INSTRUCTIONS, diff --git a/apps/server/test/internal/internal-events-tool-calls.test.ts b/apps/server/test/internal/internal-events-tool-calls.test.ts index 9ec27cd23..c34bfd79c 100644 --- a/apps/server/test/internal/internal-events-tool-calls.test.ts +++ b/apps/server/test/internal/internal-events-tool-calls.test.ts @@ -1,9 +1,20 @@ import { eq } from "drizzle-orm"; -import { closeSession, events, threads } from "@bb/db"; +import { + closeSession, + events, + getEnvironment, + getThread, + listEnvironments, + threads, +} from "@bb/db"; import { threadScope, turnScope } from "@bb/domain"; import type { HostDaemonEventEnvelope } from "@bb/host-daemon-contract"; import { describe, expect, it, vi } from "vitest"; -import { internalAuthHeaders } from "../helpers/commands.js"; +import { + internalAuthHeaders, + reportQueuedCommandSuccess, + waitForQueuedCommand, +} from "../helpers/commands.js"; import { readJson } from "../helpers/json.js"; import { seedEvent, @@ -31,6 +42,31 @@ async function postEventBatch(args: { }); } +async function postToolCall(args: { + arguments?: unknown; + callId?: string; + harness: TestAppHarness; + providerThreadId?: string; + sessionId: string; + threadId: string; + tool: string; + turnId?: string; +}): Promise { + return args.harness.app.request("/internal/session/tool-call", { + method: "POST", + headers: internalAuthHeaders(args.harness), + body: JSON.stringify({ + sessionId: args.sessionId, + threadId: args.threadId, + providerThreadId: args.providerThreadId ?? "provider-tool-call", + turnId: args.turnId ?? "turn-tool-call", + callId: args.callId ?? "call-tool-call", + tool: args.tool, + arguments: args.arguments, + }), + }); +} + describe("internal event and tool-call routes", () => { it("appends event batches and returns accepted event indexes", async () => { await withTestHarness(async (harness) => { @@ -418,6 +454,184 @@ describe("internal event and tool-call routes", () => { }); }); + it("updates a thread to an existing environment for the requested host path", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const currentEnvironment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/current-environment", + }); + const targetEnvironment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/existing-managed-worktree", + managed: true, + workspaceProvisionType: "managed-worktree", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: currentEnvironment.id, + }); + + const response = await postToolCall({ + harness, + sessionId: session.id, + threadId: thread.id, + tool: "update_environment_directory", + arguments: { path: "/tmp/existing-managed-worktree/" }, + }); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toMatchObject({ + success: true, + contentItems: [ + { + type: "inputText", + text: expect.stringContaining( + "Environment directory updated to /tmp/existing-managed-worktree", + ), + }, + ], + }); + expect(getThread(harness.db, thread.id)?.environmentId).toBe( + targetEnvironment.id, + ); + expect(listEnvironments(harness.db, project.id)).toHaveLength(2); + const storedEvents = harness.db + .select() + .from(events) + .where(eq(events.threadId, thread.id)) + .all(); + expect(storedEvents).toHaveLength(1); + expect(storedEvents[0]?.type).toBe("system/operation"); + }); + }); + + it("creates an unmanaged environment for a new requested host path", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const currentEnvironment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/current-environment", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: currentEnvironment.id, + }); + + const responsePromise = postToolCall({ + harness, + sessionId: session.id, + threadId: thread.id, + tool: "update_environment_directory", + arguments: { path: "/tmp/new-unmanaged-worktree" }, + }); + const provisionCommand = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "environment.provision" && + command.workspaceProvisionType === "unmanaged" && + command.path === "/tmp/new-unmanaged-worktree" && + command.initiator?.threadId === thread.id, + ); + if (provisionCommand.command.type !== "environment.provision") { + throw new Error("Expected environment.provision command"); + } + + await reportQueuedCommandSuccess(harness, provisionCommand, { + path: "/tmp/new-unmanaged-worktree", + isGitRepo: true, + isWorktree: true, + branchName: "feature/new-worktree", + defaultBranch: "main", + transcript: [], + }); + const response = await responsePromise; + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toMatchObject({ + success: true, + contentItems: [ + { + type: "inputText", + text: expect.stringContaining( + "Environment directory updated to /tmp/new-unmanaged-worktree", + ), + }, + ], + }); + const targetEnvironment = listEnvironments(harness.db, project.id).find( + (environment) => environment.path === "/tmp/new-unmanaged-worktree", + ); + expect(targetEnvironment).toMatchObject({ + hostId: host.id, + projectId: project.id, + status: "ready", + workspaceProvisionType: "unmanaged", + }); + expect(getThread(harness.db, thread.id)?.environmentId).toBe( + targetEnvironment?.id, + ); + expect( + targetEnvironment + ? getEnvironment(harness.db, targetEnvironment.id) + : null, + ).toMatchObject({ + branchName: "feature/new-worktree", + isGitRepo: true, + isWorktree: true, + }); + }); + }); + + it("rejects relative update_environment_directory paths without changing the thread", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + }); + + const response = await postToolCall({ + harness, + sessionId: session.id, + threadId: thread.id, + tool: "update_environment_directory", + arguments: { path: "../other-checkout" }, + }); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toMatchObject({ + success: false, + contentItems: [ + { + type: "inputText", + text: "Path must be an absolute path on the current host.", + }, + ], + }); + expect(getThread(harness.db, thread.id)?.environmentId).toBe( + environment.id, + ); + expect(listEnvironments(harness.db, project.id)).toHaveLength(1); + }); + }); + it("rejects unsupported tool calls", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index c1888c5e2..adf31282b 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -598,9 +598,20 @@ describe("thread runtime config", () => { `/tmp/bb-host-data/${hostId}/thread-storage/${thread.id}`, ); expect(runtimeConfig.workspaceProvisionType).toBe("unmanaged"); + expect(runtimeConfig.dynamicTools).toEqual([ + expect.objectContaining({ + name: "update_environment_directory", + inputSchema: expect.objectContaining({ + required: ["path"], + }), + }), + ]); expect(runtimeConfig.instructions).toContain( "You are working inside bb, an agentic IDE", ); + expect(runtimeConfig.instructions).toContain( + "update_environment_directory", + ); }); }); diff --git a/packages/db/test/data/terminal-sessions.test.ts b/packages/db/test/data/terminal-sessions.test.ts index 0299016ba..a8d3bef7e 100644 --- a/packages/db/test/data/terminal-sessions.test.ts +++ b/packages/db/test/data/terminal-sessions.test.ts @@ -243,6 +243,7 @@ describe("terminal sessions", () => { environmentId: fixture.environment.id, hostId: fixture.host.id, initialCwd: "/tmp/workspace", + now: 1, rows: 24, status: "starting", threadId: fixture.thread.id, @@ -254,6 +255,7 @@ describe("terminal sessions", () => { environmentId: fixture.environment.id, hostId: fixture.host.id, initialCwd: "/tmp/workspace", + now: 2, rows: 24, status: "running", threadId: fixture.thread.id, @@ -265,6 +267,7 @@ describe("terminal sessions", () => { environmentId: fixture.environment.id, hostId: fixture.host.id, initialCwd: "/tmp/workspace", + now: 3, rows: 24, status: "disconnected", threadId: fixture.thread.id, @@ -276,6 +279,7 @@ describe("terminal sessions", () => { environmentId: fixture.environment.id, hostId: fixture.host.id, initialCwd: "/tmp/workspace", + now: 4, rows: 24, status: "exited", threadId: fixture.thread.id, diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 3aeb148e7..a03b221c2 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -113,7 +113,7 @@ export const templateDefinitions = [ }, { "id": "standardAgentAppendInstructions", - "body": "You are working inside bb, an agentic IDE that you can use via the `bb` CLI. If you need to orchestrate work across bb (create/inspect/message threads), or the user instructs you to use bb, you may use the `bb` CLI.", + "body": "You are working inside bb, an agentic IDE that you can use via the `bb` CLI. If you need to orchestrate work across bb (create/inspect/message threads), or the user instructs you to use bb, you may use the `bb` CLI. If the user asks you to move this thread to another checkout, worktree, or directory, make sure the target directory exists, then call `update_environment_directory` with its absolute path. After it succeeds, stop work in the current turn; future turns will run in the updated environment.", "fileName": "standard-agent-append-instructions.md", "kind": "instruction", "title": "Standard Agent Append Instructions", diff --git a/packages/templates/src/templates/standard-agent-append-instructions.md b/packages/templates/src/templates/standard-agent-append-instructions.md index 5eae290d8..d28e260d3 100644 --- a/packages/templates/src/templates/standard-agent-append-instructions.md +++ b/packages/templates/src/templates/standard-agent-append-instructions.md @@ -6,4 +6,4 @@ intent: Let the agent know bb is available without causing unnecessary orchestra editingNotes: Preserve concise bb framing and keep this compatible with instructionMode append. --- -You are working inside bb, an agentic IDE that you can use via the `bb` CLI. If you need to orchestrate work across bb (create/inspect/message threads), or the user instructs you to use bb, you may use the `bb` CLI. +You are working inside bb, an agentic IDE that you can use via the `bb` CLI. If you need to orchestrate work across bb (create/inspect/message threads), or the user instructs you to use bb, you may use the `bb` CLI. If the user asks you to move this thread to another checkout, worktree, or directory, make sure the target directory exists, then call `update_environment_directory` with its absolute path. After it succeeds, stop work in the current turn; future turns will run in the updated environment. diff --git a/tests/integration/fake/smoke/lifecycle.test.ts b/tests/integration/fake/smoke/lifecycle.test.ts index a129796df..9844854d7 100644 --- a/tests/integration/fake/smoke/lifecycle.test.ts +++ b/tests/integration/fake/smoke/lifecycle.test.ts @@ -178,8 +178,12 @@ describe.sequential("fake provider smoke lifecycle integration", () => { expect(parentRuntimeCommand.commandType).toBe("thread/start"); expect(childRuntimeCommand.commandType).toBe("thread/start"); - expect(parentRuntimeCommand.dynamicToolNames).toEqual([]); - expect(childRuntimeCommand.dynamicToolNames).toEqual([]); + expect(parentRuntimeCommand.dynamicToolNames).toEqual([ + "update_environment_directory", + ]); + expect(childRuntimeCommand.dynamicToolNames).toEqual([ + "update_environment_directory", + ]); expect(parentRuntimeCommand.instructions).toContain( "If you need to orchestrate work across bb", );