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:
parent
e470684006
commit
ed2dceab73
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -155,6 +155,9 @@ export {
|
|||
getWorkspaceAgentsMdPath,
|
||||
writeWorkspaceOpenCodeAgentsMd,
|
||||
} from "./opencode-agents-md.js";
|
||||
export {
|
||||
writeOpenCodeConfig,
|
||||
} from "./opencode-config.js";
|
||||
export {
|
||||
getOrchestratorSessionId,
|
||||
normalizeOrchestratorSessionStrategy,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] && {
|
||||
|
|
|
|||
Loading…
Reference in New Issue