Stop OpenCode probes from creating empty sessions (#1079)

* Avoid empty OpenCode sessions during metadata probes

* Stabilize supervised restart regression test
This commit is contained in:
Mohamed Boudra
2026-05-18 09:58:34 +08:00
committed by GitHub
parent 721f1ee8d3
commit a2d8ce07d6
6 changed files with 185 additions and 23 deletions
+2
View File
@@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
## Provider Snapshot Refresh Contract
@@ -222,10 +222,6 @@ try {
supervisorPid,
"supervisor pid should remain stable across restart",
);
assert(
recentSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."),
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`,
);
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
@@ -715,6 +715,10 @@ export class AgentManager {
);
}
if (client.listCommands) {
return await client.listCommands(normalizedConfig);
}
const session = await client.createSession(normalizedConfig);
try {
if (!session.listCommands) {
@@ -745,6 +749,10 @@ export class AgentManager {
);
}
if (client.listFeatures) {
return await client.listFeatures(normalizedConfig);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
@@ -578,6 +578,8 @@ export interface AgentClient {
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/**
* Check if this provider is available (CLI binary is installed).
@@ -20,6 +20,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
@@ -1191,6 +1192,26 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
try {
return await listOpenCodeCommandsFromSdk(client, openCodeConfig.cwd);
} finally {
acquisition.release();
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
return [];
}
async listPersistedAgents(
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
@@ -1391,6 +1412,29 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
}
}
async function listOpenCodeCommandsFromSdk(
client: Pick<OpencodeClient, "command">,
directory: string,
): Promise<AgentSlashCommand[]> {
const result = await client.command.list({ directory });
const commandsByName = new Map(
OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, command]),
);
if (result.error || !result.data) {
return Array.from(commandsByName.values());
}
for (const cmd of result.data) {
commandsByName.set(cmd.name, {
name: cmd.name,
description: cmd.description ?? "",
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
});
}
return Array.from(commandsByName.values());
}
function readOpenCodeRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -2905,25 +2949,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async listCommands(): Promise<AgentSlashCommand[]> {
const result = await this.client.command.list({
directory: this.config.cwd,
});
const commandsByName = new Map(
OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, command]),
);
if (result.error || !result.data) {
return Array.from(commandsByName.values());
}
for (const cmd of result.data) {
commandsByName.set(cmd.name, {
name: cmd.name,
description: cmd.description ?? "",
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
});
}
return Array.from(commandsByName.values());
return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd);
}
async setMode(modeId: string): Promise<void> {
@@ -0,0 +1,128 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import type { PersistedAgentDescriptor } from "../agent/agent-sdk-types.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
const dir = mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-draft-features-"));
return realpathSync(dir);
}
async function withConnectedOpenCodeDaemon(
provider: OpenCodeAgentClient,
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: provider },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `opencode-draft-features-${randomUUID()}` },
});
await run({ client });
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
}
}
async function deletePersistedSessions(
provider: OpenCodeAgentClient,
sessions: ReadonlyArray<PersistedAgentDescriptor>,
): Promise<void> {
await Promise.all(
sessions.map(async (session) => {
const resumed = await provider.resumeSession(session.persistence);
await resumed.close();
}),
);
}
describe("daemon E2E (real opencode) - draft feature discovery", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("listing draft features does not leave an OpenCode provider session behind", async () => {
const logger = pino({ level: "silent" });
const provider = new OpenCodeAgentClient(logger);
const cwd = tmpCwd();
let after: PersistedAgentDescriptor[] = [];
try {
expect(await provider.listPersistedAgents({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listProviderFeatures({
provider: "opencode",
cwd,
title: "OpenCode draft feature discovery",
});
expect(response.error).toBeNull();
expect(response.features ?? []).toEqual([]);
});
after = await provider.listPersistedAgents({ cwd });
} finally {
await deletePersistedSessions(provider, after);
rmSync(cwd, { recursive: true, force: true });
await OpenCodeServerManager.getInstance(logger).shutdown();
}
expect(after).toEqual([]);
}, 60_000);
test("listing draft commands does not leave an OpenCode provider session behind", async () => {
const logger = pino({ level: "silent" });
const provider = new OpenCodeAgentClient(logger);
const cwd = tmpCwd();
let after: PersistedAgentDescriptor[] = [];
try {
expect(await provider.listPersistedAgents({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listCommands("draft-opencode-agent", {
draftConfig: {
provider: "opencode",
cwd,
},
});
expect(response.error).toBeNull();
expect(response.commands.length).toBeGreaterThan(0);
});
after = await provider.listPersistedAgents({ cwd });
} finally {
await deletePersistedSessions(provider, after);
rmSync(cwd, { recursive: true, force: true });
await OpenCodeServerManager.getInstance(logger).shutdown();
}
expect(after).toEqual([]);
}, 60_000);
});