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.
|
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
|
## Provider Snapshot Refresh Contract
|
||||||
|
|||||||
@@ -222,10 +222,6 @@ try {
|
|||||||
supervisorPid,
|
supervisorPid,
|
||||||
"supervisor pid should remain stable across restart",
|
"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");
|
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
||||||
} finally {
|
} finally {
|
||||||
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
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);
|
const session = await client.createSession(normalizedConfig);
|
||||||
try {
|
try {
|
||||||
if (!session.listCommands) {
|
if (!session.listCommands) {
|
||||||
@@ -745,6 +749,10 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client.listFeatures) {
|
||||||
|
return await client.listFeatures(normalizedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
const session = await client.createSession(normalizedConfig);
|
const session = await client.createSession(normalizedConfig);
|
||||||
try {
|
try {
|
||||||
return session.features ?? [];
|
return session.features ?? [];
|
||||||
|
|||||||
@@ -578,6 +578,8 @@ export interface AgentClient {
|
|||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||||
|
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||||
|
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
/**
|
/**
|
||||||
* Check if this provider is available (CLI binary is installed).
|
* Check if this provider is available (CLI binary is installed).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
|
type AgentFeature,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
type AgentModelDefinition,
|
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(
|
async listPersistedAgents(
|
||||||
options?: ListPersistedAgentsOptions,
|
options?: ListPersistedAgentsOptions,
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): 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 {
|
function readOpenCodeRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
@@ -2905,25 +2949,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const result = await this.client.command.list({
|
return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMode(modeId: string): Promise<void> {
|
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