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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user