feat: enable worker→orchestrator dialogue via `ao send` with auto-sender prefix (#1787)

* 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) <noreply@anthropic.com>

* 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,
"<prefix>-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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* refactor: literal-render orchestrator ID in prompt; auto-prefix [from <id>] 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 "<your message>"

   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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-10 21:50:43 +05:30 committed by GitHub
parent 71326bc87e
commit fe33bb7330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 187 additions and 2 deletions

View File

@ -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 <prefix>-orchestrator "<message>"` 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)

View File

@ -68,6 +68,7 @@ let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
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 <session>] 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<string, unknown> {
return {

View File

@ -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;

View File

@ -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 "<your message>"');
// 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 "<your message>"');
});
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", () => {

View File

@ -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 <sender-session-id>]`, 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} "<your message>"\``,
``,
`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));

View File

@ -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);