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