From 920b500011c1a866195dd4260410f815f2d593b5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:18:30 +0000 Subject: [PATCH] test(server): verify peer messages preserve answerable questions Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior. Co-authored-by: Dominic Anaeto --- .../peer-message-permissions.test.ts | 129 ++++++++++++++++++ packages/server/src/server/session.test.ts | 69 +--------- 2 files changed, 130 insertions(+), 68 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts diff --git a/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts b/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts new file mode 100644 index 0000000000..bc2803a65e --- /dev/null +++ b/packages/server/src/server/daemon-e2e/peer-message-permissions.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import type { FormInfo } from "@opencode/client"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js"; +import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +async function createQuestionHarness() { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const root = await mkdtemp(path.join(artifacts, "peer-permissions-")); + const runtime = new V2Harness(); + runtime.autoComplete = false; + runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] }); + runtime.api.provider.list = async () => ({ + location: runtime.info.location, + data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }], + }); + let form: FormInfo | null = null; + const answers: Array> = []; + runtime.api.session.form.list = async () => (form ? [form] : []); + runtime.api.session.form.cancel = async () => { + form = null; + runtime.finishExecution("interrupted"); + }; + runtime.api.session.form.reply = async ({ answer }) => { + answers.push(answer); + form = null; + runtime.finishExecution(); + }; + // A steer is admitted to the existing execution, rather than starting another one. + runtime.prompt = async (input) => { + runtime.prompts.push(input.text); + if (runtime.prompts.length === 1) runtime.startExecution(); + }; + const provider = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: runtime.runtime, + }); + const daemon = await createTestPaseoDaemon({ + agentClients: { ...createTestAgentClients(), opencode: provider }, + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const recipient = await client.createAgent({ + provider: "opencode", + cwd: root, + title: "Waiting for a human answer", + }); + const sender = await client.createAgent({ provider: "claude", cwd: root }); + await client.sendMessage(recipient.id, "Ask which color to use"); + await expect.poll(() => runtime.prompts).toHaveLength(1); + form = { + id: "color-question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", title: "Color", type: "string" }], + }; + runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } }); + await client.waitForAgentUpsert(recipient.id, (agent) => + (agent.pendingPermissions ?? []).some((request) => request.id === "color-question"), + ); + return { + client, + recipient, + sender, + answers, + async close() { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + }, + }; + } catch (error) { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +test("an agent-sourced steer leaves the question pending and answerable", async () => { + const { client, recipient, sender, answers, close } = await createQuestionHarness(); + try { + await client.sendAgentMessage(recipient.id, "Peer status check", { + sourceAgentId: sender.id, + }); + // The public wait includes admitted message delivery before reporting permission status. + const waiting = await client.waitForFinish(recipient.id); + expect(waiting.status).toBe("permission"); + expect(waiting.final?.pendingPermissions).toMatchObject([ + { id: "color-question", kind: "question" }, + ]); + await client.respondToPermission(recipient.id, "color-question", { + behavior: "allow", + updatedInput: { answers: { Color: "Blue" } }, + }); + await client.waitForAgentUpsert( + recipient.id, + (agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle", + ); + expect(answers).toEqual([{ color: "Blue" }]); + } finally { + await close(); + } +}); + +test("a human steer still dismisses the question", async () => { + const { client, recipient, answers, close } = await createQuestionHarness(); + try { + await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" }); + const finished = await client.waitForFinish(recipient.id); + expect(finished.status).toBe("idle"); + expect(finished.final?.pendingPermissions ?? []).toEqual([]); + expect(answers).toEqual([]); + } finally { + await close(); + } +}); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 9a13c70534..d1408f9880 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -67,7 +67,6 @@ import { createProviderSnapshot, createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; -import { createTestAgentMessages } from "./test-utils/agent-messages.js"; import { isPlatform } from "../test-utils/platform.js"; import { GitHubAuthenticationError, @@ -313,7 +312,6 @@ interface SessionForTestOptions { permissions?: readonly DaemonPermission[]; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; - agentMessages?: SessionOptions["agentMessages"]; github?: Partial; checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; workspaceGitService?: { @@ -390,7 +388,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const sessionOptions: SessionOptions = { workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), - agentMessages: options.agentMessages ?? createAgentMessageDeliveryStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), @@ -5678,71 +5676,6 @@ test("project.list returns every active project descriptor", async () => { ]); }); -describe("send_agent_message_request pending permissions", () => { - const recipientId = "22222222-2222-4222-8222-222222222222"; - const senderId = "33333333-3333-4333-8333-333333333333"; - - function steerableRecipient() { - return { - getAgent: vi.fn((agentId: string) => - agentId === recipientId - ? { id: recipientId, provider: "claude", lifecycle: "running" } - : null, - ), - waitForAgentClose: vi.fn().mockResolvedValue(undefined), - tryRunOutOfBand: vi.fn().mockResolvedValue(false), - reloadStalePluginSession: vi.fn().mockResolvedValue(undefined), - steerOrReplaceActiveTurn: vi.fn().mockResolvedValue({ status: "steered" }), - }; - } - - test("an agent-sourced message steers without clearing the recipient's pending permissions", async () => { - const agentManager = steerableRecipient(); - const agentMessages = createTestAgentMessages({ - agentManager: asAgentManager(agentManager), - agentStorage: asAgentStorage({ get: vi.fn().mockResolvedValue(undefined) }), - logger: pino({ level: "silent" }), - }); - const session = createSessionForTest({ agentManager, agentMessages }); - - await session.handleMessage({ - type: "send_agent_message_request", - requestId: "agent-send", - agentId: recipientId, - text: "Status check: reply when done.", - sourceAgentId: senderId, - messageId: "agent-message", - }); - await agentMessages.close(); - - expect(agentManager.steerOrReplaceActiveTurn).toHaveBeenCalledWith( - recipientId, - expect.anything(), - { clientMessageId: "agent-message" }, - ); - }); - - test("a sourceless steer still clears the recipient's pending permissions", async () => { - const agentManager = steerableRecipient(); - const session = createSessionForTest({ agentManager }); - - await session.handleMessage({ - type: "send_agent_message_request", - requestId: "sourceless-send", - agentId: recipientId, - text: "Status check: reply when done.", - messageId: "sourceless-message", - activeTurnBehavior: "steer", - }); - - expect(agentManager.steerOrReplaceActiveTurn).toHaveBeenCalledWith( - recipientId, - expect.anything(), - { clientMessageId: "sourceless-message", clearPendingPermissions: true }, - ); - }); -}); - describe("agent config setters", () => { function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): { [K in keyof SessionOptions["agentManager"]]?: unknown;