From 3109060216e940dbfd6afaca6024d60b4d6bcdd8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 28 May 2026 16:25:00 +0530 Subject: [PATCH] fix(acp): cover smoke parity gaps (#29719) --- packages/opencode/src/acp-next/service.ts | 36 ++++--- packages/opencode/src/acp-next/session.ts | 7 ++ .../test/acp-next/service-session.test.ts | 9 ++ .../test/cli/acp-next/lifecycle.test.ts | 24 ++++- .../test/cli/acp-next/prompt-content.test.ts | 98 +++++++++++++++++++ 5 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/test/cli/acp-next/prompt-content.test.ts diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index f4614a5c8d..2f8147ef67 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -259,21 +259,35 @@ export function make(input: { ), "session", ) - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const serverEntries = sessions.map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.directory, + title: item.title, + updatedAt: new Date(item.time.updated).toISOString(), + }), + ) + const liveEntries = (yield* session.list(params.cwd ?? undefined)) + .filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id)) + .map( + (item): SessionInfo => ({ + sessionId: item.id, + cwd: item.cwd, + updatedAt: item.createdAt.toISOString(), + }), + ) + const sorted = [...liveEntries, ...serverEntries].toSorted( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(), + ) const filtered = - cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor) + cursor === undefined || !Number.isFinite(cursor) + ? sorted + : sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor) const page = filtered.slice(0, limit) const last = page.at(-1) return { - sessions: page.map( - (item): SessionInfo => ({ - sessionId: item.id, - cwd: item.directory, - title: item.title, - updatedAt: new Date(item.time.updated).toISOString(), - }), - ), - ...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}), + sessions: page, + ...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}), } }) diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts index 6ab61f5f0a..fa803c9781 100644 --- a/packages/opencode/src/acp-next/session.ts +++ b/packages/opencode/src/acp-next/session.ts @@ -60,6 +60,7 @@ export type PartMetadataLookupInput = { export type Interface = { readonly create: (input: StoreInput) => Effect.Effect readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect readonly get: (sessionId: string) => Effect.Effect readonly tryGet: (sessionId: string) => Effect.Effect readonly remove: (sessionId: string) => Effect.Effect @@ -168,6 +169,12 @@ export const layer = Layer.effect( return Service.of({ create: store, load: store, + list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), get, tryGet, remove, diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 8ad243b047..a35e6aa0ae 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -323,6 +323,15 @@ describe("ACP next service sessions", () => { expect(second.sessions).toEqual(first.sessions) }) + it("includes live ACP sessions before they appear in server-backed session list", async () => { + const { service } = makeService() + const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" })) + + expect(listed.sessions[0]?.sessionId).toBe(created.sessionId) + expect(listed.sessions[0]?.cwd).toBe("/workspace") + }) + it("lists all sessions with next cursor when the first page is full", async () => { const { service } = makeService() const first = await Effect.runPromise(service.listSessions({})) diff --git a/packages/opencode/test/cli/acp-next/lifecycle.test.ts b/packages/opencode/test/cli/acp-next/lifecycle.test.ts index 8e9793921f..a1b54037af 100644 --- a/packages/opencode/test/cli/acp-next/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp-next/lifecycle.test.ts @@ -1,5 +1,10 @@ import { describe, expect } from "bun:test" -import type { CloseSessionResponse, LoadSessionResponse, ResumeSessionResponse } from "@agentclientprotocol/sdk" +import type { + CloseSessionResponse, + ListSessionsResponse, + LoadSessionResponse, + ResumeSessionResponse, +} from "@agentclientprotocol/sdk" import { Duration, Effect } from "effect" import { cliIt } from "../../lib/cli-process" import { expectOk, selectConfigOption } from "../acp/acp-test-client" @@ -60,6 +65,23 @@ describe("opencode acp-next lifecycle subprocess", () => { 60_000, ) + cliIt.live( + "list request includes a live ACP-created session", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + const listed = expectOk(yield* acp.request("session/list", { cwd: home })) + + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) + }), + 60_000, + ) + cliIt.live( "resume capability advertisement", ({ opencode }) => diff --git a/packages/opencode/test/cli/acp-next/prompt-content.test.ts b/packages/opencode/test/cli/acp-next/prompt-content.test.ts new file mode 100644 index 0000000000..3cf70a7af6 --- /dev/null +++ b/packages/opencode/test/cli/acp-next/prompt-content.test.ts @@ -0,0 +1,98 @@ +import { describe, expect } from "bun:test" +import type { PromptResponse } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import { writeFile } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { cliIt } from "../../lib/cli-process" +import { expectOk } from "../acp/acp-test-client" +import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" + +const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + +describe("opencode acp-next prompt content subprocess", () => { + cliIt.live( + "accepts embedded text resource image and file resource link prompt content", + ({ home, llm, opencode }) => + Effect.gen(function* () { + yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n")) + const acp = yield* createAcpNextClient( + { opencode }, + { OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) }, + ) + yield* initialize(acp) + const session = yield* newSession(acp, home) + + yield* llm.text("embedded resource accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this embedded resource." }, + { + type: "resource", + resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" }, + }, + ], + }), + ) + + yield* llm.text("image accepted") + expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this image." }, + { + type: "image", + mimeType: "image/png", + data: tinyPng, + }, + ], + }), + ) + + yield* llm.text("file link accepted") + const linked = expectOk( + yield* acp.request("session/prompt", { + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Use this linked file." }, + { + type: "resource_link", + uri: pathToFileURL(path.join(home, "README.md")).href, + name: "README.md", + mimeType: "text/markdown", + }, + ], + }), + ) + + expect(linked.stopReason).toBe("end_turn") + }), + 60_000, + ) +}) + +function promptContentConfig(llmUrl: string) { + const config = verifierConfig(llmUrl) + return { + ...config, + provider: { + test: { + ...config.provider.test, + models: Object.fromEntries( + Object.entries(config.provider.test.models).map(([id, model]) => [ + id, + { + ...model, + attachment: true, + reasoning: true, + }, + ]), + ), + }, + }, + } +}