feat(pi): make extension result timeout configurable via provider params and increase default to 30s (#1732)
* feat(pi): make extension result timeout configurable via provider params * fix(acp): add tests asserting cwd and mcpServers are always passed to session/load (#1593) Add unit tests in acp-agent.test.ts that verify initializeResumedSession() always calls loadSession and unstable_resumeSession with { sessionId, cwd, mcpServers } — even when mcpServers is an empty array. Some strict ACP providers (e.g., Devin CLI) return 'Invalid params' if any of these fields are omitted. Also adds a docstring above initializeResumedSession() documenting this requirement so future refactors don't accidentally drop params. Closes #1593 * fix(acp): address Greptile review on session/load invariant tests - Extract shared makeTestSession() factory to eliminate duplicated TestSession class definitions across all three tests (concern #1) - Pass handle through typed constructor option instead of casting private initialHandle field (concern #2) - Add missing type imports for AgentCapabilityFlags and AgentPersistenceHandle * fix(tests): ensure ACP agent session load invariant * fix(pi): increase default extension result timeout to 30s
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
|||||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||||
import { transformPiModels } from "./pi/agent.js";
|
import { transformPiModels } from "./pi/agent.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
import * as spawnUtils from "../../../utils/spawn.js";
|
import * as spawnUtils from "../../../utils/spawn.js";
|
||||||
@@ -2457,3 +2458,117 @@ describe("ACPAgentClient probe cleanup", () => {
|
|||||||
expect(child.stderr.destroyed).toBe(true);
|
expect(child.stderr.destroyed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ACP session/load invariant — cwd and mcpServers always passed", () => {
|
||||||
|
/**
|
||||||
|
* Shared factory: creates an ACPAgentSession subclass whose spawnProcess
|
||||||
|
* returns stubbed ACP internals so tests can inspect connection method calls
|
||||||
|
* without spawning real processes. Each call produces fresh vi.fn() stubs.
|
||||||
|
*/
|
||||||
|
function makeTestSession(args: {
|
||||||
|
capabilities?: AgentCapabilityFlags;
|
||||||
|
handle: AgentPersistenceHandle;
|
||||||
|
loadSession?: ReturnType<typeof vi.fn>;
|
||||||
|
unstableResumeSession?: ReturnType<typeof vi.fn>;
|
||||||
|
}) {
|
||||||
|
const loadSession =
|
||||||
|
args.loadSession ??
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
sessionId: "session-1",
|
||||||
|
modes: null,
|
||||||
|
models: null,
|
||||||
|
configOptions: [],
|
||||||
|
});
|
||||||
|
const unstableResumeSession =
|
||||||
|
args.unstableResumeSession ??
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
sessionId: "session-1",
|
||||||
|
modes: null,
|
||||||
|
models: null,
|
||||||
|
configOptions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestSession extends ACPAgentSession {
|
||||||
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
|
return {
|
||||||
|
child: createProbeChildStub(),
|
||||||
|
connection: {
|
||||||
|
prompt: vi.fn(),
|
||||||
|
loadSession,
|
||||||
|
unstable_resumeSession: unstableResumeSession,
|
||||||
|
} as unknown as ClientSideConnection,
|
||||||
|
initialize: { agentCapabilities: args.capabilities ?? {} },
|
||||||
|
} as SpawnedACPProcess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass handle through the typed constructor option (no private-field casts).
|
||||||
|
const session = new TestSession(
|
||||||
|
{ provider: "claude-acp", cwd: "/tmp/paseo-acp-test" },
|
||||||
|
{
|
||||||
|
provider: "claude-acp",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
defaultCommand: ["claude", "--acp"],
|
||||||
|
defaultModes: [],
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
...args.capabilities,
|
||||||
|
},
|
||||||
|
handle: args.handle,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { session, loadSession, unstableResumeSession };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadSession is always called with sessionId, cwd, and mcpServers even when mcpServers is empty", async () => {
|
||||||
|
const { session, loadSession } = makeTestSession({
|
||||||
|
capabilities: { loadSession: true, supportsMcpServers: true },
|
||||||
|
handle: { sessionId: "session-1", provider: "claude-acp" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.initializeResumedSession();
|
||||||
|
|
||||||
|
expect(loadSession).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
cwd: "/tmp/paseo-acp-test",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadSession is always called with mcpServers even when supportsMcpServers is false", async () => {
|
||||||
|
const { session, loadSession } = makeTestSession({
|
||||||
|
capabilities: { loadSession: true, supportsMcpServers: false },
|
||||||
|
handle: { sessionId: "session-1", provider: "claude-acp" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.initializeResumedSession();
|
||||||
|
|
||||||
|
// Even with supportsMcpServers=false, mcpServers: [] must still be passed
|
||||||
|
expect(loadSession).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
cwd: "/tmp/paseo-acp-test",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unstable_resumeSession is always called with sessionId, cwd, and mcpServers", async () => {
|
||||||
|
const { session, unstableResumeSession } = makeTestSession({
|
||||||
|
capabilities: { sessionCapabilities: { resume: {} } },
|
||||||
|
handle: { sessionId: "session-1", provider: "claude-acp" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.initializeResumedSession();
|
||||||
|
|
||||||
|
expect(unstableResumeSession).toHaveBeenCalledWith({
|
||||||
|
sessionId: "session-1",
|
||||||
|
cwd: "/tmp/paseo-acp-test",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1333,6 +1333,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
await this.applyConfiguredOverrides();
|
await this.applyConfiguredOverrides();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMPORTANT: Some ACP providers (e.g., Devin CLI) require all three params
|
||||||
|
* (sessionId, cwd, mcpServers) to be present in session/load or
|
||||||
|
* unstable_resumeSession — even when mcpServers is an empty array — and
|
||||||
|
* return "Invalid params" if any are omitted. Never drop cwd or mcpServers
|
||||||
|
* from these calls regardless of capabilities.
|
||||||
|
*/
|
||||||
async initializeResumedSession(): Promise<void> {
|
async initializeResumedSession(): Promise<void> {
|
||||||
const handle = this.initialHandle;
|
const handle = this.initialHandle;
|
||||||
if (!handle) {
|
if (!handle) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
|
|||||||
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
|
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
|
||||||
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
|
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
|
||||||
const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
|
const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
|
||||||
const PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS = 10_000;
|
const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000;
|
||||||
const QUESTION_RESPONSE_HEADER = "Response";
|
const QUESTION_RESPONSE_HEADER = "Response";
|
||||||
const QUESTION_COMMENT_HEADER = "Comment";
|
const QUESTION_COMMENT_HEADER = "Comment";
|
||||||
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
||||||
@@ -98,6 +98,7 @@ const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
|
|||||||
export const PiProviderParamsSchema = z
|
export const PiProviderParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
sessionDir: z.string().min(1).optional(),
|
sessionDir: z.string().min(1).optional(),
|
||||||
|
extensionTimeoutMs: z.number().int().positive().default(DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ interface PiRpcAgentSessionOptions {
|
|||||||
initialState: PiSessionState;
|
initialState: PiSessionState;
|
||||||
capabilities: AgentCapabilityFlags;
|
capabilities: AgentCapabilityFlags;
|
||||||
cleanup?: () => void;
|
cleanup?: () => void;
|
||||||
|
extensionTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PiResumeConfig {
|
interface PiResumeConfig {
|
||||||
@@ -1011,6 +1013,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
normalizePiThinkingOption(options.config.thinkingOptionId) ??
|
normalizePiThinkingOption(options.config.thinkingOptionId) ??
|
||||||
this.state.thinkingLevel ??
|
this.state.thinkingLevel ??
|
||||||
null;
|
null;
|
||||||
|
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||||
|
|
||||||
this.runtimeSession.onEvent((event) => {
|
this.runtimeSession.onEvent((event) => {
|
||||||
this.handleRuntimeEvent(event);
|
this.handleRuntimeEvent(event);
|
||||||
@@ -1020,6 +1023,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
private readonly runtimeSession: PiRuntimeSession;
|
private readonly runtimeSession: PiRuntimeSession;
|
||||||
private readonly config: AgentSessionConfig;
|
private readonly config: AgentSessionConfig;
|
||||||
private readonly cleanup?: () => void;
|
private readonly cleanup?: () => void;
|
||||||
|
private readonly extensionTimeoutMs: number;
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
return this.state.sessionId;
|
return this.state.sessionId;
|
||||||
@@ -1419,7 +1423,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
this.pendingExtensionResults.delete(requestId);
|
this.pendingExtensionResults.delete(requestId);
|
||||||
reject(new Error(`Pi extension result timed out for request ${requestId}`));
|
reject(new Error(`Pi extension result timed out for request ${requestId}`));
|
||||||
}, PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS);
|
}, this.extensionTimeoutMs);
|
||||||
this.pendingExtensionResults.set(requestId, { resolve, reject, timer });
|
this.pendingExtensionResults.set(requestId, { resolve, reject, timer });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1911,6 +1915,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
initialState: await runtimeSession.getState(),
|
initialState: await runtimeSession.getState(),
|
||||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||||
|
extensionTimeoutMs: this.providerParams.extensionTimeoutMs,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await runtimeSession.close().catch(() => undefined);
|
await runtimeSession.close().catch(() => undefined);
|
||||||
@@ -1961,6 +1966,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
initialState: await runtimeSession.getState(),
|
initialState: await runtimeSession.getState(),
|
||||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||||
|
extensionTimeoutMs: this.providerParams.extensionTimeoutMs,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await runtimeSession.close().catch(() => undefined);
|
await runtimeSession.close().catch(() => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user