From fe33bb73302b69f26ff0d7e2e8283d0a4ffeae22 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 10 May 2026 21:50:43 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20enable=20worker=E2=86=92orchestrator=20?= =?UTF-8?q?dialogue=20via=20`ao=20send`=20with=20auto-sender=20prefix=20(#?= =?UTF-8?q?1787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enable workers to message orchestrator via AO_ORCHESTRATOR_SESSION_ID Worker sessions can already be messaged by the orchestrator via `ao send`, but the reverse direction was undiscoverable: workers had no way to learn their orchestrator's session ID. The transport already exists (`ao send` routes to any session in the project, and the orchestrator's ID is deterministic at `${sessionPrefix}-orchestrator`) — only the discoverability piece was missing. Changes: - Add `orchestratorSessionId?: SessionId` to `AgentLaunchConfig`. - Populate it in spawnWorker and the worker restore path; deliberately omit it for spawnOrchestrator (an orchestrator is not its own parent). - Each agent plugin (claude-code, codex, opencode, aider, cursor, kimicode) now injects `AO_ORCHESTRATOR_SESSION_ID` into the agent env when the field is present. - Worker prompt preamble teaches the new channel with two restraints: (1) only ping when genuinely blocked, (2) always prefix with `[from $AO_SESSION_ID]` because the orchestrator receives raw input with no `from:` metadata. No new CLI verb, no new file format, no new transport — just env wiring plus prompt copy. Closes #1786 Co-Authored-By: Claude Opus 4.7 (1M context) * fix: only set AO_ORCHESTRATOR_SESSION_ID when orchestrator metadata exists Greptile review on #1787: spawn() unconditionally injected the env var for every worker, including ad-hoc `ao spawn` workers in projects that never had an orchestrator running. The AgentLaunchConfig JSDoc claimed the field was unset for ad-hoc sessions, but the implementation didn't honor it — so a worker following the prompt's `ao send $AO_ORCHESTRATOR_SESSION_ID …` instruction would get an opaque "session does not exist" error. Both spawn() and restore() now check `readMetadataRaw(sessionsDir, "-orchestrator")` and only propagate the field when the orchestrator's metadata is actually on disk. Existence-on-disk is the right signal: if metadata was ever written for the canonical orchestrator ID, the orchestrator workflow is in play for this project. Tests updated: - spawn: split into two cases — "passes when orchestrator exists" (now spawns the orchestrator first) and "omits for ad-hoc workers". - restore: added a third case for ad-hoc worker restore (no orchestrator metadata) alongside the existing worker-with-orchestrator and orchestrator-restore cases. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: document orchestrator-send command for POSIX, PowerShell, and cmd.exe Greptile review on #1787: the prompt's `ao send $AO_ORCHESTRATOR_SESSION_ID "[from $AO_SESSION_ID] …"` example is bash-only. On Windows PowerShell (the default shell), bare `$AO_ORCHESTRATOR_SESSION_ID` resolves to $null, so the command silently sends to an empty session ID instead of the orchestrator. CROSS_PLATFORM.md flags exactly this footgun. Both prompt variants now show the correct form for the three shells AO supports as a first-class platform: POSIX bash/zsh, PowerShell (`$env:NAME`), and cmd.exe (`%NAME%`). The agent picks the form for its shell. Quotes added around the POSIX expansion as a defensive measure in case the env var ever expands to whitespace. prompt-builder tests now assert all three syntaxes appear in both the full and no-repo prompts. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: literal-render orchestrator ID in prompt; auto-prefix [from ] in ao send Two simplifications collapsed into the same feature now that the worker→orchestrator dialogue lives in the prompt + send.ts only: 1. **Drop AO_ORCHESTRATOR_SESSION_ID env var entirely.** The orchestrator session ID is deterministic (`${sessionPrefix}-orchestrator`) and known at prompt-build time, so prompt-builder just renders it literally: ao send my-orchestrator "" No env var, no AgentLaunchConfig field, no per-plugin wiring, no PowerShell/cmd.exe/POSIX shell-syntax variants. The orchestrator existence check moves into session-manager's buildPrompt call site — one place instead of duplicated across env injection + prompt mention. 2. **Auto-prefix `[from $AO_SESSION_ID]` in `ao send` itself.** The prompt no longer teaches the agent to self-identify because that's infrastructure's job. send.ts wraps the message when AO_SESSION_ID is set, which covers all session→session traffic (worker→orchestrator, orchestrator→worker, worker→worker). Humans running ao send from their own terminal stay unprefixed. Net: 21 files changed, +174 / −249. Zero plugin code touched. No cross-platform shell footgun. Files: - types.ts: drop orchestratorSessionId field - session-manager.ts: drop spawn/restore field injection; pass orchestratorSessionId into buildPrompt instead, gated on metadata existence on disk - 6 agent plugins: drop AO_ORCHESTRATOR_SESSION_ID env injection + tests - prompt-builder.ts: add orchestratorSessionId to PromptBuildConfig; conditionally emit "Talking to the Orchestrator" section with literal ID; remove the section from the static base prompts - send.ts: auto-prefix when AO_SESSION_ID is set - send.test.ts: 3 new tests for prefix-set / prefix-unset / SessionManager delivery; existing tests preserved by clearing AO_SESSION_ID in beforeEach - changeset: scope drops to ao-core + ao-cli only Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/orchestrator-session-id-env.md | 6 + packages/cli/__tests__/commands/send.test.ts | 106 ++++++++++++++++++ packages/cli/src/commands/send.ts | 12 +- .../core/src/__tests__/prompt-builder.test.ts | 30 +++++ packages/core/src/prompt-builder.ts | 27 ++++- packages/core/src/session-manager.ts | 8 ++ 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 .changeset/orchestrator-session-id-env.md diff --git a/.changeset/orchestrator-session-id-env.md b/.changeset/orchestrator-session-id-env.md new file mode 100644 index 000000000..8fcf20027 --- /dev/null +++ b/.changeset/orchestrator-session-id-env.md @@ -0,0 +1,6 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-cli": minor +--- + +Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index 7955966f6..ea4d36cde 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -68,6 +68,7 @@ let program: Command; let consoleSpy: ReturnType; let consoleErrorSpy: ReturnType; let exitSpy: ReturnType; +let savedSessionEnv: string | undefined; beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -86,6 +87,12 @@ beforeEach(() => { mockSessionManager.send.mockReset(); mockConfigRef.current = null; mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + // Tests assume the caller is a human (no AO session). Tests that need to + // simulate session-to-session sends override AO_SESSION_ID explicitly. This + // matters because the test process itself often runs inside an AO worker, + // which would leak its own AO_SESSION_ID into the auto-prefix logic. + savedSessionEnv = process.env["AO_SESSION_ID"]; + delete process.env["AO_SESSION_ID"]; }); afterEach(() => { @@ -93,6 +100,8 @@ afterEach(() => { consoleSpy.mockRestore(); consoleErrorSpy.mockRestore(); exitSpy.mockRestore(); + if (savedSessionEnv === undefined) delete process.env["AO_SESSION_ID"]; + else process.env["AO_SESSION_ID"] = savedSessionEnv; }); describe("send command", () => { @@ -275,6 +284,103 @@ describe("send command", () => { }); }); + describe("auto-prefix from AO_SESSION_ID", () => { + it("prefixes the message with [from ] when AO_SESSION_ID is set", async () => { + process.env["AO_SESSION_ID"] = "app-7"; + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "has-session") return ""; + if (args[0] === "capture-pane") return "❯ "; + return ""; + }); + mockDetectActivity.mockReturnValueOnce("idle").mockReturnValueOnce("active"); + + await program.parseAsync(["node", "test", "send", "app-orchestrator", "hi", "boss"]); + + expect(mockExec).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "app-orchestrator", + "-l", + "[from app-7] hi boss", + ]); + }); + + it("does not prefix when AO_SESSION_ID is unset (human caller)", async () => { + // beforeEach already deletes AO_SESSION_ID — exercise that path. + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "has-session") return ""; + if (args[0] === "capture-pane") return "❯ "; + return ""; + }); + mockDetectActivity.mockReturnValueOnce("idle").mockReturnValueOnce("active"); + + await program.parseAsync(["node", "test", "send", "app-1", "hi", "there"]); + + expect(mockExec).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "app-1", + "-l", + "hi there", + ]); + }); + + it("auto-prefixes when delivering through SessionManager.send too", async () => { + process.env["AO_SESSION_ID"] = "app-orchestrator"; + mockConfigRef.current = { + configPath: "/tmp/agent-orchestrator.yaml", + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, + projects: { + "my-app": { + name: "My App", + sessionPrefix: "app", + path: "/tmp/my-app", + defaultBranch: "main", + repo: "org/my-app", + agent: "claude-code", + runtime: "tmux", + }, + }, + notifiers: {}, + notificationRouting: {}, + reactions: {}, + }; + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: "idle", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockResolvedValue(undefined); + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "capture-pane") return "❯ "; + return ""; + }); + mockDetectActivity.mockReturnValue("idle"); + + await program.parseAsync(["node", "test", "send", "app-1", "fix", "the", "build"]); + + expect(mockSessionManager.send).toHaveBeenCalledWith( + "app-1", + "[from app-orchestrator] fix the build", + ); + }); + }); + describe("session manager integration", () => { function makeConfig(): Record { return { diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index 94efc1a18..fa79cb9a1 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -135,7 +135,17 @@ export function registerSend(program: Command): void { sessionManager, } = await resolveSessionContext(session); - const message = await readMessageInput(opts, messageParts); + const rawMessage = await readMessageInput(opts, messageParts); + // Auto-prefix with the sender's session ID when ao send is invoked + // from inside an AO session (worker → orchestrator, orchestrator → + // worker, worker → worker). The receiver gets the message as raw + // terminal input with no `from:` metadata, so the prefix is the only + // way to identify who's writing. Humans running ao send from their + // own terminal have no AO_SESSION_ID and stay unprefixed. + const senderSessionId = process.env["AO_SESSION_ID"]; + const message = senderSessionId + ? `[from ${senderSessionId}] ${rawMessage}` + : rawMessage; const parsedTimeout = parseInt(opts.timeout || "600", 10); const timeoutMs = (isNaN(parsedTimeout) || parsedTimeout <= 0 ? 600 : parsedTimeout) * 1000; diff --git a/packages/core/src/__tests__/prompt-builder.test.ts b/packages/core/src/__tests__/prompt-builder.test.ts index 5274066b0..f58aed967 100644 --- a/packages/core/src/__tests__/prompt-builder.test.ts +++ b/packages/core/src/__tests__/prompt-builder.test.ts @@ -72,6 +72,36 @@ describe("buildPrompt split output", () => { expect(taskPrompt).toBeUndefined(); }); + + it("renders the orchestrator back-channel with the literal orchestrator session ID", () => { + const { systemPrompt } = buildPrompt({ + project, + projectId: "test-app", + orchestratorSessionId: "test-orchestrator", + }); + expect(systemPrompt).toContain("## Talking to the Orchestrator"); + expect(systemPrompt).toContain('ao send test-orchestrator ""'); + // No env vars or shell-syntax variants — literal ID only. + expect(systemPrompt).not.toContain("AO_ORCHESTRATOR_SESSION_ID"); + expect(systemPrompt).not.toContain("$env:"); + expect(systemPrompt).not.toContain("%AO"); + }); + + it("renders the same back-channel in the no-repo prompt when orchestrator exists", () => { + const { systemPrompt } = buildPrompt({ + project: { ...project, repo: undefined }, + projectId: "test-app", + orchestratorSessionId: "test-orchestrator", + }); + expect(systemPrompt).toContain(BASE_AGENT_PROMPT_NO_REPO); + expect(systemPrompt).toContain('ao send test-orchestrator ""'); + }); + + it("omits the orchestrator section when no orchestratorSessionId is provided", () => { + const { systemPrompt } = buildPrompt({ project, projectId: "test-app" }); + expect(systemPrompt).not.toContain("## Talking to the Orchestrator"); + expect(systemPrompt).not.toContain("ao send"); + }); }); describe("buildPrompt", () => { diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index ea40ef9c4..d2875e464 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -12,7 +12,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import type { ProjectConfig } from "./types.js"; +import type { ProjectConfig, SessionId } from "./types.js"; // ============================================================================= // LAYER 1: BASE AGENT PROMPT @@ -94,6 +94,14 @@ export interface PromptBuildConfig { /** Explicit user prompt (appended last) */ userPrompt?: string; + + /** + * Session ID of the orchestrator the worker can message back via `ao send`. + * When provided, the prompt gains a "Talking to the Orchestrator" section + * with the literal command. Caller should pass this only when an + * orchestrator session actually exists for the project. + */ + orchestratorSessionId?: SessionId; } // ============================================================================= @@ -190,6 +198,23 @@ export function buildPrompt( // Use trimmed prompt when no repo is configured (PR/CI instructions don't apply). systemSections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO); + // Layer 1b: Orchestrator back-channel — only rendered when caller passes an + // orchestratorSessionId (i.e., an orchestrator is actually running for this + // project). `ao send` auto-prefixes `[from ]`, so the + // example here is just the bare command. + if (config.orchestratorSessionId) { + systemSections.push( + [ + "## Talking to the Orchestrator", + `You can message the orchestrator session that spawned you with:`, + ``, + `\`ao send ${config.orchestratorSessionId} ""\``, + ``, + `Only do this when you genuinely cannot proceed alone — cross-session coordination, a decision only the human-facing orchestrator can make, or a blocker outside your repo's scope. Do NOT ping for things you can resolve yourself (research, retries, normal CI/review fixes go through \`ao report\` and the existing flow). \`ao send\` automatically tags the message with your session ID, so the orchestrator always knows who's writing.`, + ].join("\n"), + ); + } + // Layer 2: Worker sessions are scoped to a single issue, so issue/task // context belongs in the system prompt with the rest of the session context. systemSections.push(buildConfigLayer(config)); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index ec03dbb57..8f8976d68 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1248,12 +1248,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } + // If an orchestrator session exists on disk for this project, give the + // worker the literal command to message it. Existence-on-disk is the + // signal: if metadata was ever written for the canonical orchestrator + // ID, the orchestrator workflow is in play here. + const orchestratorSessionId = `${project.sessionPrefix}-orchestrator`; + const orchestratorExists = readMetadataRaw(sessionsDir, orchestratorSessionId) !== null; + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: spawnConfig.projectId, issueId: spawnConfig.issueId, issueContext, userPrompt: spawnConfig.prompt, + ...(orchestratorExists && { orchestratorSessionId }), }); const baseDir = getProjectDir(spawnConfig.projectId);