fix: implement PR325 session capture fallback and spawn race hardening (#366)
* fix: capture exact OpenCode session ID from JSON stream to prevent orphan sessions - Primary: Extract session_id from opencode run --format json step_start event - Fallback: Title-based match with newest-first sorting for delayed visibility - Core: Add atomic reserveSessionId check in spawnOrchestrator to prevent race conditions - Prevents orphan sessions when multiple spawns use same title or delayed discovery (cherry picked from commit42cc0cfa1a) * fix: capture OpenCode session id from stream output (cherry picked from commit42b3bf3ba6) * fix: respawn orchestrators when stale metadata is left behind (cherry picked from commite519628017) * fix: align respawn guard and opencode test expectation * fix: remove unused helper from opencode launch path * fix: remove unused function and align tests with session capture format - Remove unused buildSessionLookupScript function from opencode agent - Update integration tests to expect --format json flag in launch commands - Fix assertions for exec opencode --session wrapper format * fix: address Bugbot findings on PR #366 - Fix orphaned runtime leak when reuse strategy finds alive runtime but get() returns null (now destroys the orphaned runtime) - Restore session ID format validation in fallback script with isValidId regex check - Add regression tests for both fixes * fix: address additional Bugbot findings on PR #366 - Add timestamp helper to fallback sort to avoid NaN from invalid dates - Add session ID type/format validation to primary capture script - Add tests for both robustness improvements
This commit is contained in:
parent
a99d37b1d0
commit
cf31dee0b8
|
|
@ -2545,6 +2545,62 @@ describe("spawnOrchestrator", () => {
|
|||
expect(existsSync(listLogPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("destroys orphaned runtime when reuse strategy finds alive runtime but get returns null", 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const orphanedHandle = makeHandle("rt-orphaned");
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
worktree: join(tmpDir, "my-app"),
|
||||
branch: "main",
|
||||
status: "working",
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
runtimeHandle: JSON.stringify(orphanedHandle),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle: RuntimeHandle) => {
|
||||
if (handle?.id === "rt-orphaned") {
|
||||
deleteMetadata(sessionsDir, "app-orchestrator");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
|
||||
const session = await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(orphanedHandle);
|
||||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses mapped OpenCode session id when strategy is reuse and runtime is restarted", async () => {
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -2846,6 +2902,27 @@ describe("spawnOrchestrator", () => {
|
|||
expect(meta?.["orchestratorSessionReused"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respawns the orchestrator when stale metadata exists but the runtime is dead", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
worktree: join(tmpDir, "my-app"),
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
role: "orchestrator",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-stale")),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(false);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
|
||||
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
|
||||
expect(meta?.["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1")));
|
||||
});
|
||||
|
||||
it("uses orchestratorModel when configured", async () => {
|
||||
const configWithOrchestratorModel: OrchestratorConfig = {
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -1008,18 +1008,38 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8");
|
||||
}
|
||||
|
||||
const existingOrchestrator = await get(sessionId);
|
||||
if (existingOrchestrator?.runtimeHandle) {
|
||||
const existingAlive = await plugins.runtime
|
||||
.isAlive(existingOrchestrator.runtimeHandle)
|
||||
.catch(() => false);
|
||||
const existingOrchestratorMetadata = readMetadataRaw(sessionsDir, sessionId);
|
||||
const existingRuntimeHandle = existingOrchestratorMetadata?.["runtimeHandle"]
|
||||
? safeJsonParse<RuntimeHandle>(existingOrchestratorMetadata["runtimeHandle"])
|
||||
: null;
|
||||
if (existingRuntimeHandle) {
|
||||
const existingAlive = await plugins.runtime.isAlive(existingRuntimeHandle).catch(() => false);
|
||||
if (existingAlive && orchestratorSessionStrategy === "reuse") {
|
||||
existingOrchestrator.metadata["orchestratorSessionReused"] = "true";
|
||||
return existingOrchestrator;
|
||||
const existingOrchestrator = await get(sessionId);
|
||||
if (existingOrchestrator) {
|
||||
existingOrchestrator.metadata["orchestratorSessionReused"] = "true";
|
||||
return existingOrchestrator;
|
||||
}
|
||||
await plugins.runtime.destroy(existingRuntimeHandle).catch(() => undefined);
|
||||
} else if (existingAlive) {
|
||||
await plugins.runtime.destroy(existingRuntimeHandle).catch(() => undefined);
|
||||
}
|
||||
if (existingAlive && orchestratorSessionStrategy !== "reuse") {
|
||||
await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (!existingOrchestratorMetadata && !reserveSessionId(sessionsDir, sessionId)) {
|
||||
const raceSession = await get(sessionId);
|
||||
if (raceSession?.runtimeHandle) {
|
||||
const raceAlive = await plugins.runtime
|
||||
.isAlive(raceSession.runtimeHandle)
|
||||
.catch(() => false);
|
||||
if (raceAlive && orchestratorSessionStrategy === "reuse") {
|
||||
raceSession.metadata["orchestratorSessionReused"] = "true";
|
||||
return raceSession;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to reserve orchestrator session ID ${sessionId} (concurrent spawn detected)`,
|
||||
);
|
||||
}
|
||||
|
||||
const reusableOpenCodeSessionId =
|
||||
|
|
|
|||
|
|
@ -202,10 +202,10 @@ describe("getLaunchCommand (integration)", () => {
|
|||
prompt: "fix the bug",
|
||||
});
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
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'");
|
||||
});
|
||||
|
||||
|
|
@ -215,7 +215,7 @@ describe("getLaunchCommand (integration)", () => {
|
|||
systemPrompt: "You are an orchestrator",
|
||||
prompt: "do the task",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain("You are an orchestrator");
|
||||
expect(cmd).toContain("do the task");
|
||||
|
|
@ -227,7 +227,7 @@ describe("getLaunchCommand (integration)", () => {
|
|||
systemPromptFile: "/tmp/orchestrator-prompt.md",
|
||||
prompt: "do the task",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain(
|
||||
"$(cat '/tmp/orchestrator-prompt.md'; printf '\\n\\n'; printf %s 'do the task')",
|
||||
|
|
@ -252,7 +252,7 @@ describe("getLaunchCommand (integration)", () => {
|
|||
prompt: "review this code",
|
||||
});
|
||||
expect(cmd).toContain("--agent 'oracle'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain("You are an expert");
|
||||
expect(cmd).toContain("review this code");
|
||||
|
|
@ -277,9 +277,9 @@ describe("getLaunchCommand (integration)", () => {
|
|||
systemPromptFile: "/tmp/orchestrator-prompt.md",
|
||||
});
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:test-orchestrator' \"$(cat '/tmp/orchestrator-prompt.md')\"",
|
||||
"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");
|
||||
});
|
||||
|
||||
it("escapes single quotes in systemPrompt", () => {
|
||||
|
|
@ -303,7 +303,7 @@ describe("getLaunchCommand (integration)", () => {
|
|||
...baseConfig,
|
||||
prompt: "fix and `backtick` and 'quote'",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1'");
|
||||
expect(cmd).toContain("fix and `backtick`");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
});
|
||||
|
|
@ -313,8 +313,8 @@ describe("getLaunchCommand (integration)", () => {
|
|||
...baseConfig,
|
||||
prompt: "",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1' --command true");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
expect(cmd).toContain("opencode session list --format json");
|
||||
expect(cmd).toContain("AO:test-1");
|
||||
});
|
||||
|
|
@ -324,7 +324,7 @@ describe("getLaunchCommand (integration)", () => {
|
|||
...baseConfig,
|
||||
prompt: "line1\nline2",
|
||||
});
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1'");
|
||||
expect(cmd).toContain("'line1");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
});
|
||||
|
|
@ -336,8 +336,8 @@ describe("getLaunchCommand (integration)", () => {
|
|||
});
|
||||
expect(cmd).toContain("--title 'AO:test-1'");
|
||||
expect(cmd).not.toContain("--prompt 'start work'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:test-1' 'start work'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:test-1' 'start work'");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("uses --session when existing OpenCode session id is provided", () => {
|
||||
|
|
|
|||
|
|
@ -112,17 +112,17 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("generates base command without prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
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); }");
|
||||
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 --title 'AO:sess-1' 'Fix it'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'Fix it'");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("includes --model with shell-escaped value", () => {
|
||||
|
|
@ -135,16 +135,16 @@ describe("getLaunchCommand", () => {
|
|||
makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'",
|
||||
"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");
|
||||
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 --title 'AO:sess-1' 'it'\\''s broken'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'it'\\''s broken'");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("omits optional flags when not provided", () => {
|
||||
|
|
@ -165,8 +165,10 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --agent 'sisyphus' 'fix bug'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' --agent 'sisyphus' 'fix bug'",
|
||||
);
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
});
|
||||
|
||||
|
|
@ -179,9 +181,9 @@ describe("getLaunchCommand", () => {
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929' 'fix the bug'",
|
||||
"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("exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
|
@ -205,8 +207,8 @@ describe("getLaunchCommand", () => {
|
|||
it("backward compatible: no agent flag when subagent not provided", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix it" }));
|
||||
expect(cmd).not.toContain("--agent");
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' 'fix it'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' 'fix it'");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("combines model and prompt without agent (backward compatible)", () => {
|
||||
|
|
@ -215,9 +217,9 @@ describe("getLaunchCommand", () => {
|
|||
);
|
||||
expect(cmd).not.toContain("--agent");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'",
|
||||
"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");
|
||||
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
|
||||
});
|
||||
|
||||
|
|
@ -228,7 +230,7 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("'You are an orchestrator'");
|
||||
});
|
||||
|
||||
|
|
@ -236,7 +238,7 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPrompt: "You are an orchestrator", prompt: "do the task" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
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'");
|
||||
});
|
||||
|
|
@ -249,22 +251,26 @@ describe("getLaunchCommand", () => {
|
|||
it("handles very long systemPrompt", () => {
|
||||
const longPrompt = "A".repeat(500);
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: longPrompt }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("generates command with systemPromptFile via shell substitution", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' \"$(cat '/tmp/prompt.md')\"");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' \"$(cat '/tmp/prompt.md')\"",
|
||||
);
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("escapes path in systemPromptFile", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "/tmp/it's-prompt.md" }),
|
||||
);
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' \"$(cat '/tmp/it'\\''s-prompt.md')\"");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain(
|
||||
"opencode run --format json --title 'AO:sess-1' \"$(cat '/tmp/it'\\''s-prompt.md')\"",
|
||||
);
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("systemPromptFile takes precedence over systemPrompt", () => {
|
||||
|
|
@ -287,9 +293,9 @@ describe("getLaunchCommand", () => {
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
"opencode run --title 'AO:my-orchestrator' \"$(cat '/tmp/orchestrator.md')\"",
|
||||
"opencode run --format json --title 'AO:my-orchestrator' \"$(cat '/tmp/orchestrator.md')\"",
|
||||
);
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
});
|
||||
|
||||
it("combines systemPromptFile with subagent and prompt", () => {
|
||||
|
|
@ -301,8 +307,8 @@ describe("getLaunchCommand", () => {
|
|||
}),
|
||||
);
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
expect(cmd).toContain("--agent 'sisyphus'");
|
||||
expect(cmd).not.toContain("--prompt");
|
||||
expect(cmd).toContain(
|
||||
|
|
@ -322,7 +328,7 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("handles prompt with newlines", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1\nline2\nline3" }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1'");
|
||||
expect(cmd).toContain("'line1");
|
||||
});
|
||||
|
||||
|
|
@ -353,12 +359,36 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("handles empty prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "" }));
|
||||
expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("&& exec opencode --session");
|
||||
expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --command true");
|
||||
expect(cmd).toContain("exec opencode --session");
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -37,48 +37,6 @@ function parseSessionList(raw: string): OpenCodeSessionListEntry[] {
|
|||
});
|
||||
}
|
||||
|
||||
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, " ");
|
||||
}
|
||||
|
||||
function buildContinueSessionCommand(sessionTitle: string, sharedOptions: string[]): string {
|
||||
const script = buildSessionLookupScript();
|
||||
const options = sharedOptions.length > 0 ? ` ${sharedOptions.join(" ")}` : "";
|
||||
return [
|
||||
`SES_ID=$(opencode session list --format json | node -e ${shellEscape(script)} ${shellEscape(sessionTitle)})`,
|
||||
'[ -n "$SES_ID" ]',
|
||||
`exec opencode --session "$SES_ID"${options}`,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -138,13 +96,25 @@ function createOpenCodeAgent(): Agent {
|
|||
if (!existingSessionId) {
|
||||
const runOptions = ["--title", shellEscape(`AO:${config.sessionId}`), ...sharedOptions];
|
||||
const runCommand = promptValue
|
||||
? ["opencode", "run", ...runOptions, promptValue].join(" ")
|
||||
: ["opencode", "run", ...runOptions, "--command", "true"].join(" ");
|
||||
const continueCommand = buildContinueSessionCommand(
|
||||
`AO:${config.sessionId}`,
|
||||
sharedOptions,
|
||||
? ["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(
|
||||
" ",
|
||||
);
|
||||
return `${runCommand} && ${continueCommand}`;
|
||||
return `exec ${continueCommand}`;
|
||||
}
|
||||
|
||||
if (promptValue) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue