Browse Source

fix(acp): cover smoke parity gaps (#29719)

Shoubhit Dash 3 months ago
parent
commit
3109060216

+ 25 - 11
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()) } : {}),
     }
   })
 

+ 7 - 0
packages/opencode/src/acp-next/session.ts

@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
 export type Interface = {
   readonly create: (input: StoreInput) => Effect.Effect<Info>
   readonly load: (input: StoreInput) => Effect.Effect<Info>
+  readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
   readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
   readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
   readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
@@ -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,

+ 9 - 0
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({}))

+ 23 - 1
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<ListSessionsResponse>("session/list", { cwd: home }))
+
+        expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
+      }),
+    60_000,
+  )
+
   cliIt.live(
     "resume capability advertisement",
     ({ opencode }) =>

+ 98 - 0
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<PromptResponse>("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<PromptResponse>("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<PromptResponse>("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,
+            },
+          ]),
+        ),
+      },
+    },
+  }
+}