feat (core): inject worker prompt as instructions file (#1302)

* fix: codex worker session prompt file

* fix: clean up buildPrompt

* fix: revert system prompt change

* fix: cleanup user prompt

* feat: add opencode agents.md file write for worker agent

* feat: update writer function to write dynamic prompt

* fix: restore function to pass right agent role

* fix: lintfix

* fix: revert opencode agents.md file creation

* feat: add support for dynamic OpenCode configuration

* fix:  update OpenCode section identifiers snd docs

* chore: update tests and changeset

* chore: lintfix

* chore: fix test

* fix(prompt-builder): include issue ID in task prompt when user prompt is not provided
This commit is contained in:
Gautam Tayal 2026-04-26 15:44:39 +05:30 committed by GitHub
parent e470684006
commit ed2dceab73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 508 additions and 147 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-core": patch
---
Split worker session prompts into persistent system instructions and task-only input, materialize OpenCode worker/orchestrator instructions into session-scoped `AGENTS.md`, and keep restore behavior aligned with the updated AO prompt markers.

View File

@ -65,7 +65,7 @@ Activity states (orthogonal to lifecycle): `active`, `ready`, `idle`, `waiting_i
| ---------------------------------------- | ----------------------------------------------- |
| `packages/core/src/session-manager.ts` | Session CRUD: spawn, list, kill, send, restore |
| `packages/core/src/lifecycle-manager.ts` | State machine, polling loop, reactions engine |
| `packages/core/src/prompt-builder.ts` | 3-layer prompt assembly (base + config + rules) |
| `packages/core/src/prompt-builder.ts` | Layered worker prompt assembly (system + task) |
| `packages/core/src/config.ts` | Config loading and Zod validation |
| `packages/core/src/plugin-registry.ts` | Plugin discovery, loading, resolution |
| `packages/core/src/agent-selection.ts` | Resolves worker vs orchestrator agent roles |
@ -299,8 +299,10 @@ spawn(config)
├─ Determine branch name
├─ Create workspace (Workspace.create)
├─ Generate issue prompt (Tracker.generatePrompt)
├─ Assemble layered prompt (prompt-builder.ts) → {systemPrompt, taskPrompt}
├─ Persist worker system prompt file
├─ For OpenCode workers: write OPENCODE_CONFIG pointing at that file
├─ Build agent launch command (Agent.getLaunchCommand)
├─ Assemble full prompt (prompt-builder.ts)
├─ Create runtime session (Runtime.create)
├─ Post-launch setup (Agent.postLaunchSetup, optional)
└─ Write metadata file → return Session
@ -312,11 +314,13 @@ If issue validation fails, nothing is created — fail before allocating resourc
## Prompt Assembly
Prompts are built in three layers (`packages/core/src/prompt-builder.ts`):
Worker prompts are built in three persistent layers (`packages/core/src/prompt-builder.ts`):
1. **Base agent guidance** — standard instructions for all sessions (git workflow, PR conventions, lifecycle hooks)
2. **Config context** — project-specific info (repo, branch, issue details, agent rules from `agentRules` / `agentRulesFile`)
3. **User rules** — inlined last, highest priority
2. **Config context** — project-specific info (repo, branch, tracker, issue details, automated reactions)
3. **Project rules** — content from `agentRules` / `agentRulesFile`
The explicit user request is returned separately as `taskPrompt`. This lets session manager persist stable system instructions to disk while still sending only task-specific text to agents that need post-launch prompt delivery.
Orchestrator sessions use a separate prompt from `packages/core/src/orchestrator-prompt.ts`.

View File

@ -44,10 +44,14 @@ This document defines intended behavior for Agent Orchestrator when `agent: open
## 3) `ao spawn`
- Uses selected agent (default/project/override) and launches OpenCode command from plugin launch config.
- Worker sessions write persistent system instructions to `worker-prompt-<session>.md`.
- OpenCode launch behavior:
- no mapped id: run with `--title AO:<session>` and then continue via discovered session id.
- mapped id (`agentConfig.opencodeSessionId`): launch directly with `--session <id>`.
- Model/subagent/system prompt inputs are forwarded into OpenCode launch command.
- For OpenCode workers, core writes `OPENCODE_CONFIG` with an `instructions` array pointing at the worker prompt file instead of mutating workspace `AGENTS.md`.
- The explicit user request remains separate task text and is forwarded as `prompt` only when present.
- OpenCode orchestrators still use workspace `AGENTS.md` for persisted system prompt context.
- Model/subagent/task prompt inputs are forwarded into OpenCode launch command.
## 4) `ao send`
@ -76,6 +80,8 @@ This document defines intended behavior for Agent Orchestrator when `agent: open
- attempt title discovery using longer interactive timeout.
- if still missing, fail with non-restorable error (`OpenCode session mapping is missing`).
- Restore must recreate runtime with preserved metadata/session fields and keep mapping persisted.
- For restored OpenCode workers, core recreates `OPENCODE_CONFIG` from the saved worker prompt file when it exists.
- For restored OpenCode orchestrators, core rewrites workspace `AGENTS.md` from the saved orchestrator prompt file.
## 8) `ao session remap`

View File

@ -47,13 +47,16 @@ Handles session lifecycle:
4. Determine branch name
5. Create workspace via `Workspace.create()`
6. Generate prompt via `Tracker.generatePrompt()`
7. Build launch command via `Agent.getLaunchCommand()`
8. Create runtime session via `Runtime.create()`
9. Run `Agent.postLaunchSetup()` (optional)
10. Write metadata file
11. Return Session object
7. Build layered worker prompt via `buildPrompt()` into `systemPrompt` + `taskPrompt`
8. Persist `systemPromptFile` for the session and, for OpenCode workers, write `OPENCODE_CONFIG`
9. Build launch command via `Agent.getLaunchCommand()`
10. Create runtime session via `Runtime.create()`
11. Run `Agent.postLaunchSetup()` (optional)
12. Write metadata file
13. Return Session object
**Note:** If issue validation fails (not found, auth error), spawn fails before creating any resources (no workspace, no runtime, no session ID). This prevents spawning sessions with broken issue references.
Worker sessions keep persistent instructions in the prompt file. OpenCode workers consume that file through `OPENCODE_CONFIG`, while OpenCode orchestrators continue to project their system prompt into workspace `AGENTS.md`.
### `src/services/lifecycle-manager.ts` — State Machine + Reactions

View File

@ -3,7 +3,11 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { buildPrompt, BASE_AGENT_PROMPT, BASE_AGENT_PROMPT_NO_REPO } from "../prompt-builder.js";
import {
buildPrompt,
BASE_AGENT_PROMPT,
BASE_AGENT_PROMPT_NO_REPO,
} from "../prompt-builder.js";
import type { ProjectConfig } from "../types.js";
let tmpDir: string;
@ -26,76 +30,125 @@ afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
function combinePrompt({
systemPrompt,
taskPrompt,
}: {
systemPrompt: string;
taskPrompt?: string;
}): string {
return taskPrompt ? `${systemPrompt}\n\n${taskPrompt}` : systemPrompt;
}
describe("buildPrompt split output", () => {
it("splits persistent instructions from task-specific text", () => {
project.agentRules = "Always run pnpm test before pushing.";
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
issueContext: "## Linear Issue INT-1343\nTitle: Layered Prompt System",
userPrompt: "Focus on the API layer only.",
});
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).toContain("## Project Context");
expect(systemPrompt).toContain("## Project Rules");
expect(systemPrompt).toContain("## Task");
expect(systemPrompt).toContain("## Issue Details");
expect(systemPrompt).not.toContain("## Additional Instructions");
expect(taskPrompt).toContain("Focus on the API layer only.");
expect(taskPrompt).not.toContain("Work on issue: INT-1343");
expect(taskPrompt).not.toContain("Layered Prompt System");
});
it("omits taskPrompt for bare spawns", () => {
const { taskPrompt } = buildPrompt({
project,
projectId: "test-app",
});
expect(taskPrompt).toBeUndefined();
});
});
describe("buildPrompt", () => {
it("includes base prompt on bare spawns", () => {
const result = buildPrompt({ project, projectId: "test-app" });
expect(result).toContain(BASE_AGENT_PROMPT);
expect(result).toContain("## Project Context");
expect(result).toContain("Project: Test App");
const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app" });
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).toContain("## Project Context");
expect(systemPrompt).toContain("Project: Test App");
expect(taskPrompt).toBeUndefined();
});
it("includes base prompt when issue is provided", () => {
const result = buildPrompt({
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).not.toBeNull();
expect(result).toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(taskPrompt).toBe("Work on issue: INT-1343");
});
it("includes project context", () => {
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).toContain("Test App");
expect(result).toContain("org/test-app");
expect(result).toContain("main");
expect(systemPrompt).toContain("Test App");
expect(systemPrompt).toContain("org/test-app");
expect(systemPrompt).toContain("main");
});
it("uses trimmed base prompt when repo is not configured", () => {
const noRepoProject = { ...project, repo: undefined };
const result = buildPrompt({ project: noRepoProject, projectId: "test-app" });
expect(result).toContain(BASE_AGENT_PROMPT_NO_REPO);
expect(result).not.toContain(BASE_AGENT_PROMPT);
expect(result).not.toContain("create a PR");
expect(result).not.toContain("PR Best Practices");
expect(result).not.toContain("Repository:");
const { systemPrompt } = buildPrompt({ project: noRepoProject, projectId: "test-app" });
expect(systemPrompt).toContain(BASE_AGENT_PROMPT_NO_REPO);
expect(systemPrompt).not.toContain(BASE_AGENT_PROMPT);
expect(systemPrompt).not.toContain("create a PR");
expect(systemPrompt).not.toContain("PR Best Practices");
expect(systemPrompt).not.toContain("Repository:");
});
it("includes issue ID in task section", () => {
const result = buildPrompt({
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).toContain("Work on issue: INT-1343");
expect(result).toContain("feat/INT-1343");
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(systemPrompt).toContain("feat/INT-1343");
expect(taskPrompt).toBe("Work on issue: INT-1343");
});
it("includes issue context when provided", () => {
const result = buildPrompt({
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
issueContext: "## Linear Issue INT-1343\nTitle: Layered Prompt System\nPriority: High",
});
expect(result).toContain("## Issue Details");
expect(result).toContain("Layered Prompt System");
expect(result).toContain("Priority: High");
expect(systemPrompt).toContain("## Issue Details");
expect(systemPrompt).toContain("Layered Prompt System");
expect(systemPrompt).toContain("Priority: High");
expect(taskPrompt).toBe("Work on issue: INT-1343");
expect(taskPrompt).not.toContain("Layered Prompt System");
});
it("includes inline agentRules", () => {
project.agentRules = "Always run pnpm test before pushing.";
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).toContain("## Project Rules");
expect(result).toContain("Always run pnpm test before pushing.");
expect(systemPrompt).toContain("## Project Rules");
expect(systemPrompt).toContain("Always run pnpm test before pushing.");
});
it("reads agentRulesFile content", () => {
@ -103,13 +156,13 @@ describe("buildPrompt", () => {
writeFileSync(rulesPath, "Use conventional commits.\nNo force pushes.");
project.agentRulesFile = "agent-rules.md";
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).toContain("Use conventional commits.");
expect(result).toContain("No force pushes.");
expect(systemPrompt).toContain("Use conventional commits.");
expect(systemPrompt).toContain("No force pushes.");
});
it("includes both agentRules and agentRulesFile", () => {
@ -118,85 +171,81 @@ describe("buildPrompt", () => {
writeFileSync(rulesPath, "File rule.");
project.agentRulesFile = "rules.txt";
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
expect(result).toContain("Inline rule.");
expect(result).toContain("File rule.");
expect(systemPrompt).toContain("Inline rule.");
expect(systemPrompt).toContain("File rule.");
});
it("handles missing agentRulesFile gracefully", () => {
project.agentRulesFile = "nonexistent-rules.md";
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
});
// Should not throw, should still build prompt without rules
expect(result).not.toBeNull();
expect(result).not.toContain("## Project Rules");
expect(systemPrompt).not.toContain("## Project Rules");
});
it("appends userPrompt last", () => {
project.agentRules = "Project rule.";
const result = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
userPrompt: "Focus on the API layer only.",
});
const prompt = combinePrompt(
buildPrompt({
project,
projectId: "test-app",
issueId: "INT-1343",
userPrompt: "Focus on the API layer only.",
}),
);
expect(result).not.toBeNull();
const promptStr = result!;
// User prompt should come after project rules
const rulesIdx = promptStr.indexOf("Project rule.");
const userIdx = promptStr.indexOf("Focus on the API layer only.");
const rulesIdx = prompt.indexOf("Project rule.");
const userIdx = prompt.indexOf("Focus on the API layer only.");
expect(rulesIdx).toBeLessThan(userIdx);
expect(promptStr).toContain("## Additional Instructions");
});
it("builds prompt from rules alone (no issue)", () => {
project.agentRules = "Always lint before committing.";
const result = buildPrompt({
project,
projectId: "test-app",
});
expect(result).not.toBeNull();
expect(result).toContain(BASE_AGENT_PROMPT);
expect(result).toContain("Always lint before committing.");
const prompt = combinePrompt(
buildPrompt({
project,
projectId: "test-app",
}),
);
expect(prompt).toContain(BASE_AGENT_PROMPT);
expect(prompt).toContain("Always lint before committing.");
});
it("builds prompt from userPrompt alone (no issue, no rules)", () => {
const result = buildPrompt({
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: "test-app",
userPrompt: "Just explore the codebase.",
userPrompt: "Focus on the API layer only.",
});
expect(result).not.toBeNull();
expect(result).toContain("Just explore the codebase.");
expect(systemPrompt).toContain(BASE_AGENT_PROMPT);
expect(taskPrompt).toContain("Focus on the API layer only.");
});
it("includes tracker info in context", () => {
project.tracker = { plugin: "linear" };
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-100",
});
expect(result).toContain("Tracker: linear");
expect(systemPrompt).toContain("Tracker: linear");
});
it("uses project name in context", () => {
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "my-project",
issueId: "INT-100",
});
expect(result).toContain("Project: Test App");
expect(systemPrompt).toContain("Project: Test App");
});
it("includes reaction hints for auto send-to-agent reactions", () => {
@ -204,13 +253,13 @@ describe("buildPrompt", () => {
"ci-failed": { auto: true, action: "send-to-agent" },
"approved-and-green": { auto: false, action: "notify" },
};
const result = buildPrompt({
const { systemPrompt } = buildPrompt({
project,
projectId: "test-app",
issueId: "INT-100",
});
expect(result).toContain("ci-failed");
expect(result).not.toContain("approved-and-green");
expect(systemPrompt).toContain("ci-failed");
expect(systemPrompt).not.toContain("approved-and-green");
});
});

View File

@ -712,6 +712,80 @@ describe("restore", () => {
expect(written).toContain("<!-- AO_ORCHESTRATOR_PROMPT_START -->");
});
it("injects OPENCODE_CONFIG for restored OpenCode workers", async () => {
const wsPath = join(tmpDir, "ws-app-worker-opencode-agentsmd");
mkdirSync(wsPath, { recursive: true });
const baseDir = getProjectBaseDir(ctx.config.projects["my-app"]!.storageKey);
mkdirSync(baseDir, { recursive: true });
const promptFile = join(baseDir, "worker-prompt-app-1.md");
const promptContent = "Work on issue: TEST-1\nFix the failing tests.";
writeFileSync(promptFile, promptContent, "utf-8");
const mockOpenCodeAgent: Agent = {
...mockAgent,
name: "opencode",
getRestoreCommand: vi.fn().mockResolvedValue("opencode --session 'ses_restore'"),
};
const registryWithOpenCode: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockOpenCodeAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: wsPath,
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
agent: "opencode",
opencodeSessionId: "ses_restore",
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
});
const configWithOpenCode: OrchestratorConfig = {
...config,
defaults: { ...config.defaults, agent: "opencode" },
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"],
worker: {
agent: "opencode",
},
},
},
};
const sm = createSessionManager({
config: configWithOpenCode,
registry: registryWithOpenCode,
});
await sm.restore("app-1");
expect(mockRuntime.create).toHaveBeenCalledWith(
expect.objectContaining({
environment: expect.objectContaining({
OPENCODE_CONFIG: expect.stringContaining("opencode-config-app-1.json"),
}),
}),
);
const runtimeCreateCall = vi.mocked(mockRuntime.create).mock.calls[0][0];
const opencodeConfigPath = runtimeCreateCall.environment.OPENCODE_CONFIG;
expect(opencodeConfigPath).toBeTruthy();
expect(existsSync(opencodeConfigPath)).toBe(true);
const opencodeConfig = JSON.parse(readFileSync(opencodeConfigPath, "utf-8")) as {
instructions: string[];
};
expect(opencodeConfig.instructions).toEqual([promptFile]);
expect(existsSync(getWorkspaceAgentsMdPath(wsPath))).toBe(false);
});
it("preserves original createdAt/issue/PR metadata", async () => {
const wsPath = join(tmpDir, "ws-app-1");
mkdirSync(wsPath, { recursive: true });

View File

@ -997,6 +997,113 @@ describe("spawn", () => {
vi.useRealTimers();
});
it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({
projectId: "my-app",
issueId: "INT-1343",
prompt: "Focus on the API layer only.",
});
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "app-1",
systemPromptFile: expect.stringContaining("worker-prompt-app-1.md"),
prompt: expect.stringContaining("Focus on the API layer only."),
}),
);
const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0];
expect(callArgs.prompt).toContain("Focus on the API layer only.");
expect(callArgs.prompt).not.toContain("## Task");
expect(callArgs.prompt).not.toContain("## Project Context");
expect(callArgs.prompt).not.toContain("Session Lifecycle");
const promptFile = callArgs.systemPromptFile!;
expect(existsSync(promptFile)).toBe(true);
const systemPrompt = readFileSync(promptFile, "utf-8");
expect(systemPrompt).toContain("Session Lifecycle");
expect(systemPrompt).toContain("## Project Context");
expect(systemPrompt).toContain("## Task");
expect(systemPrompt).toContain("Work on issue: INT-1343");
expect(systemPrompt).not.toContain("## Additional Instructions");
});
it("injects OPENCODE_CONFIG for OpenCode workers", async () => {
const workspacePath = join(tmpDir, "opencode-worker-ws");
vi.mocked(mockWorkspace.create).mockResolvedValueOnce({
path: workspacePath,
branch: "feat/INT-1343",
sessionId: "app-1",
projectId: "my-app",
});
const opencodeAgent: Agent = {
...mockAgent,
name: "opencode",
};
const registryWithOpenCode: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return opencodeAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const configWithOpenCode: OrchestratorConfig = {
...config,
defaults: { ...config.defaults, agent: "opencode" },
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"],
opencodeIssueSessionStrategy: "ignore",
worker: {
agent: "opencode",
},
},
},
};
const sm = createSessionManager({
config: configWithOpenCode,
registry: registryWithOpenCode,
});
await sm.spawn({
projectId: "my-app",
issueId: "INT-1343",
prompt: "Focus on the API layer only.",
});
expect(mockRuntime.create).toHaveBeenCalledWith(
expect.objectContaining({
environment: expect.objectContaining({
OPENCODE_CONFIG: expect.stringContaining("opencode-config-app-1.json"),
}),
}),
);
const runtimeCreateCall = vi.mocked(mockRuntime.create).mock.calls[0][0];
const opencodeConfigPath = runtimeCreateCall.environment.OPENCODE_CONFIG;
expect(opencodeConfigPath).toBeTruthy();
expect(existsSync(opencodeConfigPath)).toBe(true);
const opencodeConfig = JSON.parse(readFileSync(opencodeConfigPath, "utf-8")) as {
instructions: string[];
};
expect(opencodeConfig.instructions).toHaveLength(1);
expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md");
const systemPromptPath = opencodeConfig.instructions[0]!;
expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343");
expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions");
const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath);
expect(existsSync(agentsMdPath)).toBe(false);
});
it("does not send prompt post-launch when agent.promptDelivery is not set", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" });
@ -1005,7 +1112,7 @@ describe("spawn", () => {
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
});
it("sends AO guidance post-launch even when no explicit prompt is provided", async () => {
it("does not send a post-launch message when no task prompt is available", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
@ -1024,12 +1131,47 @@ describe("spawn", () => {
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app" });
await vi.advanceTimersByTimeAsync(5_000);
await spawnPromise;
const session = await spawnPromise;
expect(mockRuntime.sendMessage).not.toHaveBeenCalled();
expect(session.metadata.promptDelivered).toBeUndefined();
vi.useRealTimers();
});
it("sends a minimal post-launch task for issue-only spawns", async () => {
vi.useFakeTimers();
const postLaunchAgent = {
...mockAgent,
promptDelivery: "post-launch" as const,
};
const registryWithPostLaunch: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return postLaunchAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithPostLaunch });
const spawnPromise = sm.spawn({ projectId: "my-app", issueId: "INT-1343" });
await vi.advanceTimersByTimeAsync(5_000);
const session = await spawnPromise;
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
expect.stringContaining("ao session claim-pr"),
"Work on issue: INT-1343",
);
expect(session.metadata.promptDelivered).toBe("true");
const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0];
expect(callArgs.prompt).toBe("Work on issue: INT-1343");
expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md");
const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8");
expect(systemPrompt).toContain("## Task");
expect(systemPrompt).toContain("Work on issue: INT-1343");
vi.useRealTimers();
});
@ -2088,6 +2230,7 @@ describe("spawn", () => {
"my-app": {
...config.projects["my-app"],
agent: "opencode",
orchestratorSessionStrategy: "ignore",
},
},
};
@ -2104,9 +2247,10 @@ describe("spawn", () => {
const agentsMdPath = getWorkspaceAgentsMdPath("/tmp/ws");
expect(existsSync(agentsMdPath)).toBe(true);
expect(readFileSync(agentsMdPath, "utf-8")).toBe(
"<!-- AO_ORCHESTRATOR_PROMPT_START -->\n## Agent Orchestrator\n\nYou are the orchestrator.\n<!-- AO_ORCHESTRATOR_PROMPT_END -->\n",
);
const written = readFileSync(agentsMdPath, "utf-8");
expect(written).toContain("<!-- AO_ORCHESTRATOR_PROMPT_START -->");
expect(written).toContain("## Agent Orchestrator");
expect(written).toContain("You are the orchestrator.");
expect(mockRuntime.create).toHaveBeenCalledWith(
expect.objectContaining({

View File

@ -155,6 +155,9 @@ export {
getWorkspaceAgentsMdPath,
writeWorkspaceOpenCodeAgentsMd,
} from "./opencode-agents-md.js";
export {
writeOpenCodeConfig,
} from "./opencode-config.js";
export {
getOrchestratorSessionId,
normalizeOrchestratorSessionStrategy,

View File

@ -0,0 +1,27 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function getOpenCodeConfigPath(baseDir: string, sessionId: string): string {
return join(baseDir, `opencode-config-${sessionId}.json`);
}
export function writeOpenCodeConfig(
baseDir: string,
sessionId: string,
instructionFiles: string[],
): string {
mkdirSync(baseDir, { recursive: true });
const configPath = getOpenCodeConfigPath(baseDir, sessionId);
writeFileSync(
configPath,
JSON.stringify(
{
instructions: instructionFiles,
},
null,
2,
),
"utf-8",
);
return configPath;
}

View File

@ -6,8 +6,8 @@
* 2. Config-derived context project name, repo, default branch, tracker info, reaction rules
* 3. User rules inline agentRules and/or agentRulesFile content
*
* buildPrompt() always returns the AO base guidance and project context so
* bare launches still know about AO-specific commands such as PR claiming.
* buildPrompt() returns the split between persistent system instructions and
* task-specific text so callers can route them to agents separately.
*/
import { readFileSync } from "node:fs";
@ -178,30 +178,32 @@ function readUserRules(project: ProjectConfig): string | null {
/**
* Compose a layered prompt for an agent session.
*
* Always returns the AO base guidance plus project context, then layers on
* issue context, user rules, and explicit instructions when available.
*/
export function buildPrompt(config: PromptBuildConfig): string {
export function buildPrompt(
config: PromptBuildConfig,
): { systemPrompt: string; taskPrompt?: string } {
const userRules = readUserRules(config.project);
const sections: string[] = [];
const systemSections: string[] = [];
// Layer 1: Base prompt is always included for every managed session.
// Use trimmed prompt when no repo is configured (PR/CI instructions don't apply).
sections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO);
systemSections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO);
// Layer 2: Config-derived context
sections.push(buildConfigLayer(config));
// 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));
// Layer 3: User rules
if (userRules) {
sections.push(`## Project Rules\n${userRules}`);
systemSections.push(`## Project Rules\n${userRules}`);
}
// Explicit user prompt (appended last, highest priority)
if (config.userPrompt) {
sections.push(`## Additional Instructions\n${config.userPrompt}`);
}
return sections.join("\n\n");
return {
systemPrompt: systemSections.join("\n\n"),
taskPrompt: config.userPrompt
? config.userPrompt
: config.issueId
? `Work on issue: ${config.issueId}`
: undefined,
};
}

View File

@ -77,6 +77,7 @@ import { asValidOpenCodeSessionId } from "./opencode-session-id.js";
import {
writeWorkspaceOpenCodeAgentsMd,
} from "./opencode-agents-md.js";
import { writeOpenCodeConfig } from "./opencode-config.js";
import {
getOrchestratorSessionId,
normalizeOrchestratorSessionStrategy,
@ -1233,7 +1234,42 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
}
const composedPrompt = buildPrompt({
const cleanupSpawnWorkspaceAndMetadata = async (
promptFile?: string,
opencodeConfigFile?: string,
): Promise<void> => {
if (
plugins.workspace &&
shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath)
) {
try {
await plugins.workspace.destroy(workspacePath);
} catch {
/* best effort */
}
}
try {
deleteMetadata(sessionsDir, sessionId, false);
} catch {
/* best effort */
}
if (promptFile) {
try {
unlinkSync(promptFile);
} catch {
/* best effort */
}
}
if (opencodeConfigFile) {
try {
unlinkSync(opencodeConfigFile);
} catch {
/* best effort */
}
}
};
const { systemPrompt, taskPrompt } = buildPrompt({
project,
projectId: spawnConfig.projectId,
issueId: spawnConfig.issueId,
@ -1241,16 +1277,40 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
userPrompt: spawnConfig.prompt,
});
let systemPromptFile: string | undefined;
// need a seperate config file to pass instructions for opencode session
let opencodeConfigFile: string | undefined;
try {
const baseDir = getProjectBaseDir(project.storageKey);
mkdirSync(baseDir, { recursive: true });
systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`);
writeFileSync(systemPromptFile, systemPrompt, "utf-8");
if (plugins.agent.name === "opencode") {
opencodeConfigFile = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]);
}
} catch (err) {
await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile);
throw err;
}
// Get agent launch config and create runtime — clean up workspace on failure
const opencodeIssueSessionStrategy = project.opencodeIssueSessionStrategy ?? "reuse";
const reusedOpenCodeSessionId =
plugins.agent.name === "opencode" && spawnConfig.issueId
? await resolveOpenCodeSessionReuse({
sessionsDir,
criteria: { issueId: spawnConfig.issueId },
strategy: opencodeIssueSessionStrategy,
})
: undefined;
let reusedOpenCodeSessionId: string | undefined;
try {
reusedOpenCodeSessionId =
plugins.agent.name === "opencode" && spawnConfig.issueId
? await resolveOpenCodeSessionReuse({
sessionsDir,
criteria: { issueId: spawnConfig.issueId },
strategy: opencodeIssueSessionStrategy,
})
: undefined;
} catch (err) {
await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile);
throw err;
}
const agentLaunchConfig = {
sessionId,
projectConfig: {
@ -1261,7 +1321,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
},
},
issueId: spawnConfig.issueId,
prompt: composedPrompt,
prompt: taskPrompt,
systemPromptFile,
permissions: selection.permissions,
model: selection.model,
subagent: spawnConfig.subagent ?? selection.subagent,
@ -1278,6 +1339,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
launchCommand,
environment: {
...environment,
...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}),
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
GH_PATH: PREFERRED_GH_PATH,
...(process.env["AO_AGENT_GH_TRACE"] && {
@ -1295,22 +1357,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
},
});
} catch (err) {
// Clean up workspace and reserved ID if agent config or runtime creation failed
if (
plugins.workspace &&
shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath)
) {
try {
await plugins.workspace.destroy(workspacePath);
} catch {
/* best effort */
}
}
try {
deleteMetadata(sessionsDir, sessionId, false);
} catch {
/* best effort */
}
// Clean up workspace, prompt file, and reserved ID if agent config or runtime creation failed
await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile);
throw err;
}
@ -1401,27 +1449,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
} catch {
/* best effort */
}
if (
plugins.workspace &&
shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath)
) {
try {
await plugins.workspace.destroy(workspacePath);
} catch {
/* best effort */
}
}
try {
deleteMetadata(sessionsDir, sessionId, false);
} catch {
/* best effort */
}
invalidateCache();
await cleanupSpawnWorkspaceAndMetadata(systemPromptFile);
throw err;
}
// Send initial prompt post-launch for agents that need it (e.g. Claude Code
// exits after -p, so we send the prompt after it starts in interactive mode).
// Send the task-specific prompt post-launch for agents that need it
// (e.g. Claude Code exits after -p, so we send the prompt after it starts
// in interactive mode).
// This is intentionally outside the try/catch above — a prompt delivery failure
// should NOT destroy the session. The agent is running; user can retry with `ao send`.
let promptDelivered = false;
@ -2851,6 +2885,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
}
let opencodeConfigPath: string | undefined;
if (plugins.agent.name === "opencode" && selection.role !== "orchestrator") {
const baseDir = getProjectBaseDir(project.storageKey);
const systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`);
if (existsSync(systemPromptFile)) {
opencodeConfigPath = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]);
}
}
// 6. Destroy old runtime if still alive (e.g. tmux session survives agent crash)
if (session.runtimeHandle) {
try {
@ -2898,6 +2941,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
launchCommand,
environment: {
...environment,
...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}),
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
GH_PATH: PREFERRED_GH_PATH,
...(process.env["AO_AGENT_GH_TRACE"] && {