From 11ea174a81616cbe42c4cd66fa727174b341fd34 Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 6 Mar 2026 11:34:29 +0530 Subject: [PATCH] fix: preserve title-only OpenCode reuse with fallback mapping persistence --- .../src/__tests__/session-manager.test.ts | 112 ++++++++++++++++++ packages/core/src/session-manager.ts | 62 +++++++--- packages/plugins/agent-opencode/src/index.ts | 2 +- 3 files changed, 157 insertions(+), 19 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index ef447d5fb..2063820f1 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -2058,6 +2058,54 @@ describe("spawnOrchestrator", () => { expect(meta?.["opencodeSessionId"]).toBeUndefined(); }); + it("discovers and persists OpenCode session id by title when strategy is reuse", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-discovery.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_discovered_orchestrator", + title: "AO:app-orchestrator", + updated: 1_772_777_000_000, + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + 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", + }, + }, + }; + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator"); + }); + it("reuses an existing orchestrator session when strategy is reuse", async () => { const opencodeAgent: Agent = { ...mockAgent, @@ -2227,6 +2275,70 @@ describe("spawnOrchestrator", () => { ); }); + it("reuses OpenCode session by title when orchestrator mapping is missing", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + 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-existing")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_title_match" }), + }), + }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["opencodeSessionId"]).toBe("ses_title_match"); + }); + it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log"); const mockBin = installMockOpencode( diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index e78935daf..33fa1274a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -115,19 +115,11 @@ async function discoverOpenCodeSessionIdsByTitle( const parsed = safeJsonParse>>(stdout); if (!parsed) return []; const title = `AO:${sessionId}`; - const candidates = parsed - .filter((entry) => { - const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : ""; - const candidateId = typeof entry["id"] === "string" ? entry["id"] : ""; - return candidateTitle === title && candidateId.length > 0; - }) - .sort((a, b) => { - const aUpdated = Date.parse(typeof a["updated"] === "string" ? a["updated"] : ""); - const bUpdated = Date.parse(typeof b["updated"] === "string" ? b["updated"] : ""); - const aScore = Number.isNaN(aUpdated) ? 0 : aUpdated; - const bScore = Number.isNaN(bUpdated) ? 0 : bUpdated; - return bScore - aScore; - }); + const candidates = parsed.filter((entry) => { + const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : ""; + const candidateId = typeof entry["id"] === "string" ? entry["id"] : ""; + return candidateTitle === title && candidateId.length > 0; + }); return candidates .map((entry) => asValidOpenCodeSessionId(entry["id"])) @@ -429,6 +421,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return undefined; } + if (candidateIds.length === 0 && criteria.sessionId) { + candidateIds = await discoverOpenCodeSessionIdsByTitle(criteria.sessionId); + } + return candidateIds[0]; } @@ -810,11 +806,25 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (plugins.agent.postLaunchSetup) { await plugins.agent.postLaunchSetup(session); + } - if (Object.keys(session.metadata || {}).length > 0) { - updateMetadata(sessionsDir, sessionId, session.metadata); + if ( + plugins.agent.name === "opencode" && + opencodeIssueSessionStrategy === "reuse" && + !session.metadata["opencodeSessionId"] + ) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (discovered) { + session.metadata["opencodeSessionId"] = discovered; } } + + if (Object.keys(session.metadata || {}).length > 0) { + updateMetadata(sessionsDir, sessionId, session.metadata); + } } catch (err) { // Clean up runtime and workspace on post-launch failure try { @@ -988,7 +998,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { agentInfo: null, createdAt: new Date(), lastActivityAt: new Date(), - metadata: {}, + metadata: { + ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}), + }, }; try { @@ -1007,11 +1019,25 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (plugins.agent.postLaunchSetup) { await plugins.agent.postLaunchSetup(session); + } - if (Object.keys(session.metadata || {}).length > 0) { - updateMetadata(sessionsDir, sessionId, session.metadata); + if ( + plugins.agent.name === "opencode" && + orchestratorSessionStrategy === "reuse" && + !session.metadata["opencodeSessionId"] + ) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (discovered) { + session.metadata["opencodeSessionId"] = discovered; } } + + if (Object.keys(session.metadata || {}).length > 0) { + updateMetadata(sessionsDir, sessionId, session.metadata); + } } catch (err) { // Clean up runtime on post-launch failure try { diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index c3abe09af..ebe60fdc8 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -113,7 +113,7 @@ function createOpenCodeAgent(): Agent { const runCommand = promptValue ? ["opencode", "run", ...runOptions, promptValue].join(" ") : ["opencode", "run", ...runOptions, "--command", "true"].join(" "); - const continueSession = `"$(opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];const rows=JSON.parse(input);if(!Array.isArray(rows))process.exit(1);const matches=rows.filter((r)=>r&&r.title===title&&typeof r.id==='string').sort((a,b)=>{const aUpdated=Date.parse(typeof a.updated==='string'?a.updated:'');const bUpdated=Date.parse(typeof b.updated==='string'?b.updated:'');const aScore=Number.isNaN(aUpdated)?0:aUpdated;const bScore=Number.isNaN(bUpdated)?0:bUpdated;return bScore-aScore;});if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)})"`; + const continueSession = `"$(opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];const rows=JSON.parse(input);if(!Array.isArray(rows))process.exit(1);const matches=rows.filter((r)=>r&&r.title===title&&typeof r.id==='string');if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)})"`; const continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join( " ", );