Skip to content

Commit 3109060

Browse files
authored
fix(acp): cover smoke parity gaps (anomalyco#29719)
1 parent 9031ce7 commit 3109060

5 files changed

Lines changed: 162 additions & 12 deletions

File tree

packages/opencode/src/acp-next/service.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -259,21 +259,35 @@ export function make(input: {
259259
),
260260
"session",
261261
)
262-
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
262+
const serverEntries = sessions.map(
263+
(item): SessionInfo => ({
264+
sessionId: item.id,
265+
cwd: item.directory,
266+
title: item.title,
267+
updatedAt: new Date(item.time.updated).toISOString(),
268+
}),
269+
)
270+
const liveEntries = (yield* session.list(params.cwd ?? undefined))
271+
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
272+
.map(
273+
(item): SessionInfo => ({
274+
sessionId: item.id,
275+
cwd: item.cwd,
276+
updatedAt: item.createdAt.toISOString(),
277+
}),
278+
)
279+
const sorted = [...liveEntries, ...serverEntries].toSorted(
280+
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
281+
)
263282
const filtered =
264-
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
283+
cursor === undefined || !Number.isFinite(cursor)
284+
? sorted
285+
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
265286
const page = filtered.slice(0, limit)
266287
const last = page.at(-1)
267288
return {
268-
sessions: page.map(
269-
(item): SessionInfo => ({
270-
sessionId: item.id,
271-
cwd: item.directory,
272-
title: item.title,
273-
updatedAt: new Date(item.time.updated).toISOString(),
274-
}),
275-
),
276-
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
289+
sessions: page,
290+
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
277291
}
278292
})
279293

packages/opencode/src/acp-next/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
6060
export type Interface = {
6161
readonly create: (input: StoreInput) => Effect.Effect<Info>
6262
readonly load: (input: StoreInput) => Effect.Effect<Info>
63+
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
6364
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
6465
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
6566
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
@@ -168,6 +169,12 @@ export const layer = Layer.effect(
168169
return Service.of({
169170
create: store,
170171
load: store,
172+
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
173+
return [...(yield* Ref.get(sessions)).values()]
174+
.filter((session) => !cwd || session.cwd === cwd)
175+
.map(snapshot)
176+
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
177+
}),
171178
get,
172179
tryGet,
173180
remove,

packages/opencode/test/acp-next/service-session.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,15 @@ describe("ACP next service sessions", () => {
323323
expect(second.sessions).toEqual(first.sessions)
324324
})
325325

326+
it("includes live ACP sessions before they appear in server-backed session list", async () => {
327+
const { service } = makeService()
328+
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
329+
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
330+
331+
expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
332+
expect(listed.sessions[0]?.cwd).toBe("/workspace")
333+
})
334+
326335
it("lists all sessions with next cursor when the first page is full", async () => {
327336
const { service } = makeService()
328337
const first = await Effect.runPromise(service.listSessions({}))

packages/opencode/test/cli/acp-next/lifecycle.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect } from "bun:test"
2-
import type { CloseSessionResponse, LoadSessionResponse, ResumeSessionResponse } from "@agentclientprotocol/sdk"
2+
import type {
3+
CloseSessionResponse,
4+
ListSessionsResponse,
5+
LoadSessionResponse,
6+
ResumeSessionResponse,
7+
} from "@agentclientprotocol/sdk"
38
import { Duration, Effect } from "effect"
49
import { cliIt } from "../../lib/cli-process"
510
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
@@ -60,6 +65,23 @@ describe("opencode acp-next lifecycle subprocess", () => {
6065
60_000,
6166
)
6267

68+
cliIt.live(
69+
"list request includes a live ACP-created session",
70+
({ home, llm, opencode }) =>
71+
Effect.gen(function* () {
72+
const acp = yield* createAcpNextClient(
73+
{ opencode },
74+
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
75+
)
76+
yield* initialize(acp)
77+
const session = yield* newSession(acp, home)
78+
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
79+
80+
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
81+
}),
82+
60_000,
83+
)
84+
6385
cliIt.live(
6486
"resume capability advertisement",
6587
({ opencode }) =>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect } from "bun:test"
2+
import type { PromptResponse } from "@agentclientprotocol/sdk"
3+
import { Effect } from "effect"
4+
import { writeFile } from "node:fs/promises"
5+
import path from "node:path"
6+
import { pathToFileURL } from "node:url"
7+
import { cliIt } from "../../lib/cli-process"
8+
import { expectOk } from "../acp/acp-test-client"
9+
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
10+
11+
const tinyPng =
12+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
13+
14+
describe("opencode acp-next prompt content subprocess", () => {
15+
cliIt.live(
16+
"accepts embedded text resource image and file resource link prompt content",
17+
({ home, llm, opencode }) =>
18+
Effect.gen(function* () {
19+
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
20+
const acp = yield* createAcpNextClient(
21+
{ opencode },
22+
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
23+
)
24+
yield* initialize(acp)
25+
const session = yield* newSession(acp, home)
26+
27+
yield* llm.text("embedded resource accepted")
28+
expectOk(
29+
yield* acp.request<PromptResponse>("session/prompt", {
30+
sessionId: session.sessionId,
31+
prompt: [
32+
{ type: "text", text: "Use this embedded resource." },
33+
{
34+
type: "resource",
35+
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
36+
},
37+
],
38+
}),
39+
)
40+
41+
yield* llm.text("image accepted")
42+
expectOk(
43+
yield* acp.request<PromptResponse>("session/prompt", {
44+
sessionId: session.sessionId,
45+
prompt: [
46+
{ type: "text", text: "Use this image." },
47+
{
48+
type: "image",
49+
mimeType: "image/png",
50+
data: tinyPng,
51+
},
52+
],
53+
}),
54+
)
55+
56+
yield* llm.text("file link accepted")
57+
const linked = expectOk(
58+
yield* acp.request<PromptResponse>("session/prompt", {
59+
sessionId: session.sessionId,
60+
prompt: [
61+
{ type: "text", text: "Use this linked file." },
62+
{
63+
type: "resource_link",
64+
uri: pathToFileURL(path.join(home, "README.md")).href,
65+
name: "README.md",
66+
mimeType: "text/markdown",
67+
},
68+
],
69+
}),
70+
)
71+
72+
expect(linked.stopReason).toBe("end_turn")
73+
}),
74+
60_000,
75+
)
76+
})
77+
78+
function promptContentConfig(llmUrl: string) {
79+
const config = verifierConfig(llmUrl)
80+
return {
81+
...config,
82+
provider: {
83+
test: {
84+
...config.provider.test,
85+
models: Object.fromEntries(
86+
Object.entries(config.provider.test.models).map(([id, model]) => [
87+
id,
88+
{
89+
...model,
90+
attachment: true,
91+
reasoning: true,
92+
},
93+
]),
94+
),
95+
},
96+
},
97+
}
98+
}

0 commit comments

Comments
 (0)