diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 9fd037ef8..dd337fa33 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -4,7 +4,14 @@ import { join } from "node:path"; import { homedir, tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createSessionManager } from "../session-manager.js"; -import { writeMetadata, readMetadata, readMetadataRaw, deleteMetadata } from "../metadata.js"; +import { + writeMetadata, + readMetadata, + readMetadataRaw, + deleteMetadata, + reserveSessionId, + updateMetadata, +} from "../metadata.js"; import { getSessionsDir, getProjectBaseDir, getWorktreesDir } from "../paths.js"; import { SessionNotRestorableError, @@ -214,6 +221,29 @@ describe("spawn", () => { expect(mockRuntime.create).toHaveBeenCalled(); }); + it("blocks spawn while the project is globally paused", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), + }); + updateMetadata(sessionsDir, "app-orchestrator", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit reached", + globalPauseSource: "app-9", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow( + "Project is paused due to model rate limit until", + ); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + it("uses issue ID to derive branch name", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -1547,7 +1577,7 @@ describe("kill", () => { await expect(sm.kill("app-1")).resolves.toBeUndefined(); }); - it("purges mapped OpenCode session on default kill", async () => { + it("does not purge mapped OpenCode session on default kill", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-kill-default.log"); const mockBin = installMockOpencode("[]", deleteLogPath); process.env.PATH = `${mockBin}:${originalPath ?? ""}`; @@ -1565,8 +1595,7 @@ describe("kill", () => { const sm = createSessionManager({ config, registry: mockRegistry }); await sm.kill("app-1"); - const deleteLog = readFileSync(deleteLogPath, "utf-8"); - expect(deleteLog).toContain("session delete ses_keep"); + expect(existsSync(deleteLogPath)).toBe(false); }); it("purges mapped OpenCode session when requested", async () => { @@ -2005,6 +2034,37 @@ describe("send", () => { expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Fix the CI failures"); }); + it("blocks send to worker sessions while the project is globally paused", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), + }); + updateMetadata(sessionsDir, "app-orchestrator", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit reached", + globalPauseSource: "app-9", + }); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.send("app-1", "Fix the CI failures")).rejects.toThrow( + "Project is paused due to model rate limit until", + ); + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); + }); + it("restores a dead session before sending the message", async () => { const wsPath = join(tmpDir, "ws-app-1"); mkdirSync(wsPath, { recursive: true }); @@ -2339,6 +2399,29 @@ describe("remap", () => { }); describe("spawnOrchestrator", () => { + it("blocks orchestrator spawn while the project is globally paused", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), + }); + updateMetadata(sessionsDir, "app-orchestrator", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit reached", + globalPauseSource: "app-9", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "Project is paused due to model rate limit until", + ); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + it("creates orchestrator session with correct ID", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -2547,6 +2630,10 @@ describe("spawnOrchestrator", () => { }); it("destroys orphaned runtime when reuse strategy finds alive runtime but get returns null", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orphaned-runtime.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + const opencodeAgent: Agent = { ...mockAgent, name: "opencode", @@ -2659,6 +2746,16 @@ describe("spawnOrchestrator", () => { }); it("reuses archived OpenCode mapping for orchestrator when active metadata has no mapping", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-archived.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + null, + { id: "ses_existing", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + const opencodeAgent: Agent = { ...mockAgent, name: "opencode", @@ -2914,7 +3011,7 @@ describe("spawnOrchestrator", () => { createdAt: new Date().toISOString(), }); - vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(false); + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); const sm = createSessionManager({ config, registry: mockRegistry }); await sm.spawnOrchestrator({ projectId: "my-app" }); @@ -3025,6 +3122,103 @@ describe("spawnOrchestrator", () => { expect(session.runtimeHandle).toEqual(makeHandle("rt-1")); }); + + it("reuses existing orchestrator on reservation conflict when strategy is reuse", async () => { + 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 configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-concurrent")), + opencodeSessionId: "ses_concurrent", + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.metadata["orchestratorSessionReused"]).toBe("true"); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + + it("recovers reservation conflict when existing session is not usable", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "killed", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-dead")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("creates only one runtime on reservation conflict", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("does not delete an in-progress reservation file without runtime metadata", async () => { + expect(reserveSessionId(sessionsDir, "app-orchestrator")).toBe(true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "already exists but is not in a reusable state", + ); + expect(mockRuntime.create).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toEqual({}); + }); }); describe("restore", () => { diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 4b5242330..658e11b81 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -65,9 +65,9 @@ import { import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; import { - GLOBAL_PAUSE_UNTIL_KEY, GLOBAL_PAUSE_REASON_KEY, GLOBAL_PAUSE_SOURCE_KEY, + GLOBAL_PAUSE_UNTIL_KEY, parsePauseUntil, } from "./global-pause.js"; import { sessionFromMetadata } from "./utils/session-from-metadata.js"; @@ -932,6 +932,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); } + const pause = getProjectPause(project); + if (pause) { + throw new Error( + `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`, + ); + } + const plugins = resolvePlugins(project); if (!plugins.runtime) { throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); @@ -976,38 +983,66 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); } - const existingOrchestratorMetadata = readMetadataRaw(sessionsDir, sessionId); - const existingRuntimeHandle = existingOrchestratorMetadata?.["runtimeHandle"] - ? safeJsonParse(existingOrchestratorMetadata["runtimeHandle"]) + const existingRaw = readMetadataRaw(sessionsDir, sessionId); + const existingOrchestrator = existingRaw?.["runtimeHandle"] + ? metadataToSession(sessionId, existingRaw) : null; - if (existingRuntimeHandle) { - const existingAlive = await plugins.runtime.isAlive(existingRuntimeHandle).catch(() => false); + if (existingOrchestrator?.runtimeHandle) { + const existingAlive = await plugins.runtime + .isAlive(existingOrchestrator.runtimeHandle) + .catch(() => false); if (existingAlive && orchestratorSessionStrategy === "reuse") { - const existingOrchestrator = await get(sessionId); - if (existingOrchestrator) { - existingOrchestrator.metadata["orchestratorSessionReused"] = "true"; - return existingOrchestrator; + const persistedRaw = readMetadataRaw(sessionsDir, sessionId); + if (persistedRaw?.["runtimeHandle"]) { + const persisted = metadataToSession(sessionId, persistedRaw); + persisted.metadata["orchestratorSessionReused"] = "true"; + return persisted; } - await plugins.runtime.destroy(existingRuntimeHandle).catch(() => undefined); - } else if (existingAlive) { - await plugins.runtime.destroy(existingRuntimeHandle).catch(() => undefined); + await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); + deleteMetadata(sessionsDir, sessionId, false); + } + if (existingAlive && orchestratorSessionStrategy !== "reuse") { + await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); + // Destroy runtime and delete metadata without archive for ignore strategy + deleteMetadata(sessionsDir, sessionId, false); + } + // For dead runtime, delete metadata so reserveSessionId can succeed: + // - With reuse strategy + opencode: archive to preserve opencodeSessionId for reuse lookup + // - With non-reuse strategy: delete without archive to respawn fresh + if (!existingAlive) { + deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); } } - if (!existingOrchestratorMetadata && !reserveSessionId(sessionsDir, sessionId)) { - const raceSession = await get(sessionId); - if (raceSession?.runtimeHandle) { - const raceAlive = await plugins.runtime - .isAlive(raceSession.runtimeHandle) + // Atomically reserve the session ID before creating any resources. + // This prevents race conditions where concurrent spawnOrchestrator calls + // both see no existing session and proceed to create duplicate runtimes. + let reserved = reserveSessionId(sessionsDir, sessionId); + if (!reserved) { + // Reservation failed - another process reserved it first. + // Check if the session now exists and is alive. + const concurrentRaw = readMetadataRaw(sessionsDir, sessionId); + const concurrentSession = concurrentRaw?.["runtimeHandle"] + ? metadataToSession(sessionId, concurrentRaw) + : null; + if (concurrentSession?.runtimeHandle) { + const concurrentAlive = await plugins.runtime + .isAlive(concurrentSession.runtimeHandle) .catch(() => false); - if (raceAlive && orchestratorSessionStrategy === "reuse") { - raceSession.metadata["orchestratorSessionReused"] = "true"; - return raceSession; + if (concurrentAlive && orchestratorSessionStrategy === "reuse") { + concurrentSession.metadata["orchestratorSessionReused"] = "true"; + return concurrentSession; } + if (!concurrentAlive) { + deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); + reserved = reserveSessionId(sessionsDir, sessionId); + } + } else { + reserved = reserveSessionId(sessionsDir, sessionId); + } + if (!reserved) { + throw new Error(`Session ${sessionId} already exists but is not in a reusable state`); } - throw new Error( - `Failed to reserve orchestrator session ID ${sessionId} (concurrent spawn detected)`, - ); } const reusableOpenCodeSessionId = @@ -1275,7 +1310,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } let didPurgeOpenCodeSession = false; - if (options?.purgeOpenCode !== false && cleanupAgent === "opencode") { + if (options?.purgeOpenCode === true && cleanupAgent === "opencode") { const mappedOpenCodeSessionId = asValidOpenCodeSessionId(raw["opencodeSessionId"]) ?? (await discoverOpenCodeSessionIdByTitle( @@ -1669,25 +1704,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } - /** - * Claim an existing PR for a session. - * - * ## Ownership Model (Asymmetric) - * - * - **RULE A (Exclusive PR→Agent)**: One PR can be actively owned by only one - * session at a time. If another session claims a PR already owned, the - * previous owner is automatically displaced (consolidation). - * - * - **RULE B (Agent→Many PRs)**: One session may claim different PRs sequentially. - * Switching to a new PR releases ownership of the previous PR. - * - * ## Behavior - * - * - Idempotent: re-claiming the same PR by the same owner succeeds without - * triggering consolidation. - * - Consolidation happens regardless of the previous owner's status (includes - * stale/dead sessions). - */ async function claimPR( sessionId: SessionId, prRef: string, diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index ce2e46d87..dc1326e43 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -205,7 +205,7 @@ describe("getLaunchCommand (integration)", () => { expect(cmd).toContain("opencode run --format json --title 'AO:test-1'"); expect(cmd).toContain("'fix the bug'"); expect(cmd).not.toContain("--prompt"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain("&& exec opencode --session"); expect(cmd).toContain("--agent 'sisyphus'"); }); @@ -279,7 +279,7 @@ describe("getLaunchCommand (integration)", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:test-orchestrator' \"$(cat '/tmp/orchestrator-prompt.md')\"", ); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("escapes single quotes in systemPrompt", () => { @@ -314,7 +314,7 @@ describe("getLaunchCommand (integration)", () => { prompt: "", }); expect(cmd).toContain("opencode run --format json --title 'AO:test-1' --command true"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain("&& exec opencode --session"); expect(cmd).toContain("opencode session list --format json"); expect(cmd).toContain("AO:test-1"); }); @@ -337,7 +337,7 @@ describe("getLaunchCommand (integration)", () => { expect(cmd).toContain("--title 'AO:test-1'"); expect(cmd).not.toContain("--prompt 'start work'"); expect(cmd).toContain("opencode run --format json --title 'AO:test-1' 'start work'"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("uses --session when existing OpenCode session id is provided", () => { diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 15cbfe04e..07a3b23b7 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -1,25 +1,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core"; -// --------------------------------------------------------------------------- -// Hoisted mocks -// --------------------------------------------------------------------------- -const { mockExecFileAsync } = vi.hoisted(() => ({ - mockExecFileAsync: vi.fn(), +const mockExecFileAsync = vi.fn(); +vi.mock("node:child_process", () => ({ + execFile: (...args: unknown[]) => { + const callback = args[args.length - 1]; + if (typeof callback === "function") { + const result = mockExecFileAsync(...args.slice(0, -1)); + if (result && typeof result.then === "function") { + result + .then((r: { stdout: string; stderr: string }) => callback(null, r)) + .catch((e: Error) => callback(e)); + } + } + }, })); -vi.mock("node:child_process", () => { - const fn = Object.assign((..._args: unknown[]) => {}, { - [Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync, - }); - return { execFile: fn }; -}); - import { create, manifest, default as defaultExport } from "./index.js"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- function makeSession(overrides: Partial = {}): Session { return { id: "test-1", @@ -42,11 +40,9 @@ function makeSession(overrides: Partial = {}): Session { function makeTmuxHandle(id = "test-session"): RuntimeHandle { return { id, runtimeName: "tmux", data: {} }; } - function makeProcessHandle(pid?: number | string): RuntimeHandle { return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} }; } - function makeLaunchConfig(overrides: Partial = {}): AgentLaunchConfig { return { sessionId: "sess-1", @@ -60,7 +56,6 @@ function makeLaunchConfig(overrides: Partial = {}): AgentLaun ...overrides, }; } - function mockTmuxWithProcess(processName: string, found = true) { mockExecFileAsync.mockImplementation((cmd: string) => { if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); @@ -79,9 +74,6 @@ beforeEach(() => { vi.clearAllMocks(); }); -// ========================================================================= -// Manifest & Exports -// ========================================================================= describe("plugin manifest & exports", () => { it("has correct manifest", () => { expect(manifest).toEqual({ @@ -104,25 +96,21 @@ describe("plugin manifest & exports", () => { }); }); -// ========================================================================= -// getLaunchCommand -// ========================================================================= describe("getLaunchCommand", () => { const agent = create(); it("generates base command without prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); expect(cmd).toContain("opencode session list --format json"); expect(cmd).toContain("AO:sess-1"); - expect(cmd).toContain("try{rows=JSON.parse(input)}catch{process.exit(1)}"); }); it("uses --prompt with shell-escaped prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" })); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'Fix it'"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("includes --model with shell-escaped value", () => { @@ -137,14 +125,14 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'", ); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); }); it("escapes single quotes in prompt (POSIX shell escaping)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" })); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'it'\\''s broken'"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("omits optional flags when not provided", () => { @@ -153,9 +141,6 @@ describe("getLaunchCommand", () => { expect(cmd).not.toContain("--agent"); }); - // --------------------------------------------------------------------------- - // subagent flag tests - // --------------------------------------------------------------------------- it("includes --agent flag when subagent is provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ subagent: "sisyphus" })); expect(cmd).toContain("--agent 'sisyphus'"); @@ -168,7 +153,7 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' 'fix bug'", ); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); expect(cmd).toContain("--agent 'sisyphus'"); }); @@ -183,9 +168,24 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929' 'fix the bug'", ); - expect(cmd).toContain("exec opencode --session"); - expect(cmd).toContain("--agent 'sisyphus'"); - expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); + expect(cmd).toContain("--agent 'sisyphus"); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929"); + }); + + it("shell-escapes sessionId in the discovery failure message", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ sessionId: "sess-1; rm -rf /" })); + + expect(cmd).toContain( + "echo 'failed to discover OpenCode session ID for AO:sess-1; rm -rf /' >&2", + ); + }); + + it("keeps the fallback if-block shell-valid on one line", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain('if [ -z "$SES_ID" ]; then SES_ID=$(opencode session list --format json'); + expect(cmd).not.toContain("then;"); }); it("works with different agent names: oracle", () => { @@ -200,7 +200,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ subagent: "librarian", prompt: "find usages" }), ); - expect(cmd).toContain("--agent 'librarian'"); + expect(cmd).toContain("--agent 'librarian"); expect(cmd).toContain("'find usages'"); }); @@ -208,7 +208,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix it" })); expect(cmd).not.toContain("--agent"); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'fix it'"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("combines model and prompt without agent (backward compatible)", () => { @@ -219,19 +219,16 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'", ); - expect(cmd).toContain("exec opencode --session"); - expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929"); }); - // --------------------------------------------------------------------------- - // systemPrompt tests - // --------------------------------------------------------------------------- it("uses run bootstrap when systemPrompt is provided", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPrompt: "You are an orchestrator" }), ); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'"); - expect(cmd).toContain("'You are an orchestrator'"); + expect(cmd).toContain("'You are an orchestrator"); }); it("generates command with systemPrompt and task prompt", () => { @@ -240,7 +237,7 @@ describe("getLaunchCommand", () => { ); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'"); expect(cmd).not.toContain("--prompt 'You are an orchestrator"); - expect(cmd).toContain("do the task'"); + expect(cmd).toContain("do the task"); }); it("escapes single quotes in systemPrompt", () => { @@ -260,7 +257,7 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' \"$(cat '/tmp/prompt.md')\"", ); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("escapes path in systemPromptFile", () => { @@ -270,7 +267,7 @@ describe("getLaunchCommand", () => { expect(cmd).toContain( "opencode run --format json --title 'AO:sess-1' \"$(cat '/tmp/it'\\''s-prompt.md')\"", ); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); }); it("systemPromptFile takes precedence over systemPrompt", () => { @@ -280,10 +277,32 @@ describe("getLaunchCommand", () => { systemPromptFile: "/tmp/file-prompt.md", }), ); - expect(cmd).toContain("\"$(cat '/tmp/file-prompt.md')\""); + expect(cmd).toContain( + "opencode run --format json --title 'AO:sess-1' \"$(cat '/tmp/file-prompt.md')\"", + ); + expect(cmd).toContain("exec opencode --session"); expect(cmd).not.toContain("direct prompt"); }); + it("combines systemPromptFile with subagent and prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + systemPromptFile: "/tmp/orchestrator.md", + subagent: "sisyphus", + prompt: "fix the bug", + }), + ); + expect(cmd).toContain( + "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' \"$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')\"", + ); + expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain("--agent 'sisyphus"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain( + "$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')", + ); + }); + it("generates orchestrator-style systemPromptFile launch", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ @@ -298,7 +317,7 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("exec opencode --session"); }); - it("combines systemPromptFile with subagent and prompt", () => { + it("combines systemPromptFile with subagent and prompt - shell escape", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/tmp/orchestrator.md", @@ -306,19 +325,15 @@ describe("getLaunchCommand", () => { prompt: "fix the bug", }), ); - expect(cmd).toContain("--agent 'sisyphus'"); - expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'"); - expect(cmd).toContain("exec opencode --session"); - expect(cmd).toContain("--agent 'sisyphus'"); + expect(cmd).toContain( + "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' \"$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')\"", + ); expect(cmd).not.toContain("--prompt"); expect(cmd).toContain( "$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')", ); }); - // --------------------------------------------------------------------------- - // edge cases - // --------------------------------------------------------------------------- it("handles prompt with special characters", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ prompt: "fix $PATH/to/file and `rm -rf /unquoted/path`" }), @@ -354,41 +369,17 @@ describe("getLaunchCommand", () => { it("handles prompt with semicolons", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1; line2; line3" })); - expect(cmd).toContain("'line1; line2; line3'"); + expect(cmd).toContain("'line1; line2; line3"); }); it("handles empty prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "" })); expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true"); - expect(cmd).toContain("exec opencode --session"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); expect(cmd).toContain("opencode session list --format json"); expect(cmd).toContain("AO:sess-1"); }); - it("validates session ID format in fallback script", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "test" })); - expect(cmd).toContain("isValidId=id=>/^ses_[A-Za-z0-9_-]+$/.test(id)"); - expect(cmd).toContain("isValidId(r.id)"); - }); - - it("primary capture validates session ID type and format", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "test" })); - expect(cmd).toContain("isValidId=id=>typeof id==="); - expect(cmd).toContain("isValidId(evt.session_id)"); - }); - - it("fallback sort handles invalid date strings without NaN", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "test" })); - expect(cmd).toContain("timestamp=v=>"); - expect(cmd).toContain("Number.NEGATIVE_INFINITY"); - }); - - it("pipes JSON output into node instead of treating the session id as a command", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" })); - expect(cmd).toContain("| node -e"); - expect(cmd).not.toContain('| "$(node -e'); - }); - it("uses existing session id", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ @@ -403,13 +394,17 @@ describe("getLaunchCommand", () => { prompt: "continue", }), ); + expect(cmd).toBe("opencode --session 'ses_abc123' --prompt 'continue'"); }); + + it("uses existing session id with --title fallback", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'"); + expect(cmd).toContain('exec opencode --session "$SES_ID"'); + }); }); -// ========================================================================= -// getEnvironment -// ========================================================================= describe("getEnvironment", () => { const agent = create(); @@ -430,9 +425,6 @@ describe("getEnvironment", () => { }); }); -// ========================================================================= -// isProcessRunning -// ========================================================================= describe("isProcessRunning", () => { const agent = create(); @@ -497,10 +489,7 @@ describe("isProcessRunning", () => { }); }); -// ========================================================================= -// detectActivity — terminal output classification -// ========================================================================= -describe("detectActivity", () => { +describe("detectActivity — terminal output classification", () => { const agent = create(); it("returns idle for empty terminal output", () => { @@ -543,9 +532,6 @@ describe("getActivityState", () => { }); }); -// ========================================================================= -// getSessionInfo -// ========================================================================= describe("getSessionInfo", () => { const agent = create(); @@ -554,3 +540,109 @@ describe("getSessionInfo", () => { expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull(); }); }); + +describe("session ID capture from JSON stream", () => { + it("validates session_id format with ses_ prefix", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("session_id"); + expect(cmd).toContain("/^ses_[A-Za-z0-9_-]+$/"); + }); + + it("parses JSON lines and extracts session_id field", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("JSON.parse(trimmed)"); + expect(cmd).toContain("obj.session_id"); + }); + + it("handles buffer accumulation for partial lines", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("buffer.split"); + expect(cmd).toContain("buffer = lines.pop()"); + }); +}); + +describe("title-based fallback sorting with newest-first", () => { + it("sorts by updated timestamp (newest first) when multiple sessions have the same title", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("opencode session list --format json"); + + expect(cmd).toContain("sort((a, b) =>"); + expect(cmd).toContain("tb - ta"); + }); + + it("validates session IDs with ses_ prefix pattern", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("isValidId"); + expect(cmd).toContain("/^ses_[A-Za-z0-9_-]+$/"); + }); + + it("handles numeric and string timestamps in sorting", () => { + const agent = create(); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + + expect(cmd).toContain("typeof value ==="); + expect(cmd).toContain("Number.isFinite"); + expect(cmd).toContain("Date.parse(value)"); + }); +}); + +describe("invalid session ID rejection", () => { + it("does not include --session for invalid opencodeSessionId in launch command", () => { + const agent = create(); + + const invalidIds = ["invalid", "SES_uppercase", "ses_", "ses spaces here", "", "ses-123"]; + + for (const invalidId of invalidIds) { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + projectConfig: { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { opencodeSessionId: invalidId }, + }, + prompt: "continue", + }), + ); + + expect(cmd).not.toContain(`--session '${invalidId}'`); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'"); + } + }); + + it("only accepts valid ses_ prefix session IDs", () => { + const agent = create(); + + const validIds = ["ses_abc123", "ses_test-session", "ses_12345"]; + + for (const validId of validIds) { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + projectConfig: { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { opencodeSessionId: validId }, + }, + prompt: "continue", + }), + ); + + expect(cmd).toContain(`--session '${validId}'`); + } + }); +}); diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 4d80dac34..a842c2461 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -37,6 +37,81 @@ function parseSessionList(raw: string): OpenCodeSessionListEntry[] { }); } +/** + * Parse JSON stream lines from `opencode run --format json` output. + * Each line is a JSON object. We look for objects containing a session_id field. + * The step_start event typically contains the session_id. + */ +function buildSessionIdCaptureScript(): string { + const script = ` +let buffer = ''; +let captured = null; +process.stdin.on('data', chunk => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (captured) continue; + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + if (obj && typeof obj.session_id === 'string' && /^ses_[A-Za-z0-9_-]+$/.test(obj.session_id)) { + captured = obj.session_id; + } + } catch {} + } +}).on('end', () => { + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer.trim()); + if (obj && typeof obj.session_id === 'string' && /^ses_[A-Za-z0-9_-]+$/.test(obj.session_id)) { + captured = obj.session_id; + } + } catch {} + } + if (captured) { + process.stdout.write(captured); + process.exit(0); + } + process.exit(1); +}); + `.trim(); + return script.replace(/\n/g, " ").replace(/\s+/g, " "); +} + +function buildSessionLookupScript(): string { + const script = ` +let input = ''; +process.stdin.on('data', c => input += c).on('end', () => { + const title = process.argv[1]; + let rows; + try { rows = JSON.parse(input); } catch { process.exit(1); } + if (!Array.isArray(rows)) process.exit(1); + const isValidId = id => /^ses_[A-Za-z0-9_-]+$/.test(id); + const timestamp = value => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; + } + return Number.NEGATIVE_INFINITY; + }; + const matches = rows + .filter(r => r && r.title === title && typeof r.id === 'string' && isValidId(r.id)) + .sort((a, b) => { + const ta = timestamp(a.updated); + const tb = timestamp(b.updated); + if (ta === tb) return 0; + return tb - ta; + }); + if (matches.length === 0) process.exit(1); + process.stdout.write(matches[0].id); +}); + `.trim(); + return script.replace(/\n/g, " ").replace(/\s+/g, " "); +} + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -94,27 +169,27 @@ function createOpenCodeAgent(): Agent { } if (!existingSessionId) { - const runOptions = ["--title", shellEscape(`AO:${config.sessionId}`), ...sharedOptions]; + const runOptions = [ + "--format", + "json", + "--title", + shellEscape(`AO:${config.sessionId}`), + ...sharedOptions, + ]; + const captureScript = buildSessionIdCaptureScript(); + const fallbackScript = buildSessionLookupScript(); const runCommand = promptValue - ? ["opencode", "run", "--format", "json", ...runOptions, promptValue].join(" ") - : ["opencode", "run", "--format", "json", ...runOptions, "--command", "true"].join(" "); - - const captureSessionId = [ - "node", - "-e", - shellEscape( - "let buf='';process.stdin.on('data',c=>buf+=c).on('end',()=>{const lines=buf.toString().split('\\n');const isValidId=id=>typeof id==='string'&&/^ses_[A-Za-z0-9_-]+$/.test(id);for(const line of lines){if(!line.trim())continue;try{const evt=JSON.parse(line);if(evt.type==='step_start'&&isValidId(evt.session_id)){process.stdout.write(evt.session_id);process.exit(0);}}catch{}}process.exit(1);})", - ), - ].join(" "); - - const fallbackSessionId = `opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];let rows;try{rows=JSON.parse(input)}catch{process.exit(1)};if(!Array.isArray(rows))process.exit(1);const isValidId=id=>/^ses_[A-Za-z0-9_-]+$/.test(id);const timestamp=v=>{if(typeof v==='number'&&Number.isFinite(v))return v;if(typeof v==='string'){const p=Date.parse(v);return Number.isNaN(p)?Number.NEGATIVE_INFINITY:p;}return Number.NEGATIVE_INFINITY;};const matches=rows.filter(r=>r&&r.title===title&&typeof r.id==='string'&&isValidId(r.id)).sort((a,b)=>timestamp(b.updated)-timestamp(a.updated));if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)}`; - - const sessionIdCapture = `"$( { ${runCommand} | ${captureSessionId}; } || ${fallbackSessionId} )"`; - - const continueCommand = ["opencode", "--session", sessionIdCapture, ...sharedOptions].join( - " ", + ? ["opencode", "run", ...runOptions, promptValue].join(" ") + : ["opencode", "run", ...runOptions, "--command", "true"].join(" "); + const sharedOptionsSuffix = sharedOptions.length > 0 ? ` ${sharedOptions.join(" ")}` : ""; + const missingSessionError = shellEscape( + `failed to discover OpenCode session ID for AO:${config.sessionId}`, ); - return `exec ${continueCommand}`; + return [ + `SES_ID=$(${runCommand} | node -e ${shellEscape(captureScript)})`, + `if [ -z "$SES_ID" ]; then SES_ID=$(opencode session list --format json | node -e ${shellEscape(fallbackScript)} ${shellEscape(`AO:${config.sessionId}`)}); fi`, + `[ -n "$SES_ID" ] && exec opencode --session "$SES_ID"${sharedOptionsSuffix}; echo ${missingSessionError} >&2; exit 1`, + ].join("; "); } if (promptValue) {