Skip to content

Commit cf7098c

Browse files
committed
Merge remote-tracking branch 'upstream/dev' into feat/desktop-v2-ui
2 parents 714d799 + 1f66db0 commit cf7098c

11 files changed

Lines changed: 654 additions & 107 deletions

File tree

packages/app/e2e/regression/session-timeline-collapse-state.spec.ts

Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
1+
import { expect, test, type Locator, type Page } from "@playwright/test"
2+
import { mockOpenCodeServer } from "../utils/mock-server"
23

34
const directory = "C:/OpenCode/TimelineStateRegression"
45
const projectID = "proj_timeline_state_regression"
@@ -299,39 +300,13 @@ function readExpanded(element: Element) {
299300
}
300301

301302
async function mockServer(page: Page, events: EventPayload[]) {
302-
await page.route("**/*", async (route) => {
303-
const url = new URL(route.request().url())
304-
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
305-
if (url.port !== targetPort) return route.fallback()
306-
307-
const path = url.pathname
308-
if (path === "/global/event") return sse(route, events.splice(0))
309-
if (
310-
path === "/global/config" ||
311-
path === "/config" ||
312-
path === "/provider/auth" ||
313-
path === "/mcp" ||
314-
path === "/session/status"
315-
)
316-
return json(route, {})
317-
if (
318-
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
319-
path,
320-
)
321-
)
322-
return json(route, [])
323-
if (path === "/provider") return json(route, provider())
324-
if (path === "/path")
325-
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
326-
if (path === "/project") return json(route, [project()])
327-
if (path === "/project/current") return json(route, project())
328-
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
329-
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
330-
if (path === "/session") return json(route, [session()])
331-
if (path === `/session/${sessionID}`) return json(route, session())
332-
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
333-
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
334-
return json(route, {})
303+
await mockOpenCodeServer(page, {
304+
directory,
305+
project: project(),
306+
provider: provider(),
307+
sessions: [session()],
308+
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
309+
events: () => events.splice(0),
335310
})
336311
}
337312

@@ -372,24 +347,6 @@ function provider() {
372347
}
373348
}
374349

375-
function json(route: Route, body: unknown, headers?: Record<string, string>) {
376-
return route.fulfill({
377-
status: 200,
378-
contentType: "application/json",
379-
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
380-
body: JSON.stringify(body ?? null),
381-
})
382-
}
383-
384-
function sse(route: Route, events: EventPayload[]) {
385-
return route.fulfill({
386-
status: 200,
387-
contentType: "text/event-stream",
388-
headers: { "access-control-allow-origin": "*" },
389-
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
390-
})
391-
}
392-
393350
function base64Encode(value: string) {
394351
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
395352
}

packages/app/e2e/regression/session-timeline-context-resize.spec.ts

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { expect, test, type Page, type Route } from "@playwright/test"
1+
import { expect, test, type Page } from "@playwright/test"
2+
import { mockOpenCodeServer } from "../utils/mock-server"
23

34
const directory = "C:/OpenCode/ContextResizeRegression"
45
const projectID = "proj_context_resize_regression"
@@ -207,33 +208,12 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
207208
}
208209

209210
async function mockServer(page: Page) {
210-
await page.route("**/*", async (route) => {
211-
const url = new URL(route.request().url())
212-
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
213-
if (url.port !== targetPort) return route.fallback()
214-
215-
const path = url.pathname
216-
if (path === "/global/event" || path === "/event") return sse(route)
217-
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
218-
return json(route, {})
219-
if (
220-
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
221-
path,
222-
)
223-
)
224-
return json(route, [])
225-
if (path === "/provider") return json(route, provider())
226-
if (path === "/path")
227-
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
228-
if (path === "/project") return json(route, [project()])
229-
if (path === "/project/current") return json(route, project())
230-
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
231-
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
232-
if (path === "/session") return json(route, [session()])
233-
if (path === `/session/${sessionID}`) return json(route, session())
234-
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
235-
if (path === `/session/${sessionID}/message`) return json(route, messages)
236-
return json(route, {})
211+
await mockOpenCodeServer(page, {
212+
directory,
213+
project: project(),
214+
provider: provider(),
215+
sessions: [session()],
216+
pageMessages: () => ({ items: messages }),
237217
})
238218
}
239219

@@ -282,19 +262,6 @@ function provider() {
282262
}
283263
}
284264

285-
function json(route: Route, body: unknown, headers?: Record<string, string>) {
286-
return route.fulfill({
287-
status: 200,
288-
contentType: "application/json",
289-
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
290-
body: JSON.stringify(body ?? null),
291-
})
292-
}
293-
294-
function sse(route: Route) {
295-
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
296-
}
297-
298265
function base64Encode(value: string) {
299266
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
300267
}

packages/app/e2e/utils/mock-server.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface MockServerConfig {
1818
project: unknown
1919
sessions: ({ id: string } & Record<string, unknown>)[]
2020
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
21+
events?: () => unknown[]
2122
}
2223

2324
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -43,7 +44,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
4344
if (url.port !== targetPort) return route.fallback()
4445

4546
const path = url.pathname
46-
if (path === "/global/event" || path === "/event") return sse(route)
47+
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
48+
if (path === "/global/health") return json(route, { healthy: true })
4749
if (emptyObject.has(path)) return json(route, {})
4850
if (emptyList.has(path)) return json(route, [])
4951
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -81,6 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
8183
})
8284
}
8385

84-
function sse(route: Route) {
85-
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
86+
function sse(route: Route, events?: unknown[]) {
87+
return route.fulfill({
88+
status: 200,
89+
contentType: "text/event-stream",
90+
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
91+
})
8692
}

packages/app/src/entry.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,11 @@ if (root instanceof HTMLElement) {
168168
() => (
169169
<PlatformProvider value={platform}>
170170
<AppBaseProviders>
171-
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
171+
<AppInterface
172+
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
173+
servers={[server]}
174+
disableHealthCheck
175+
/>
172176
</AppBaseProviders>
173177
</PlatformProvider>
174178
),

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,18 @@ import type {
77
OpencodeClient,
88
Part,
99
SessionMessageResponse,
10+
ToolPart,
1011
} from "@opencode-ai/sdk/v2"
1112
import { Effect } from "effect"
1213
import { ACPNextSession } from "./session"
14+
import {
15+
duplicateRunningToolUpdate,
16+
errorToolUpdate,
17+
pendingToolCall,
18+
runningToolUpdate,
19+
shellOutputSnapshot,
20+
completedToolUpdate,
21+
} from "./tool"
1322

1423
const log = Log.create({ service: "acp-next-event" })
1524

@@ -29,6 +38,8 @@ export function start(input: { sdk: OpencodeClient; connection: Connection; sess
2938

3039
export class Subscription {
3140
private readonly abort = new AbortController()
41+
private readonly shellSnapshots = new Map<string, string>()
42+
private readonly toolStarts = new Set<string>()
3243
private started = false
3344

3445
constructor(
@@ -61,6 +72,17 @@ export class Subscription {
6172
}
6273
}
6374

75+
async replayMessage(message: SessionMessageResponse) {
76+
if (message.info.role !== "assistant" && message.info.role !== "user") return
77+
78+
for (const part of message.parts) {
79+
await this.recordFetchedPart(message.info.sessionID, message, part)
80+
if (part.type === "tool") {
81+
await this.handleToolPart(message.info.sessionID, part)
82+
}
83+
}
84+
}
85+
6486
private async run() {
6587
while (!this.abort.signal.aborted) {
6688
const events = (await this.input.sdk.global.event({
@@ -96,6 +118,9 @@ export class Subscription {
96118
metadata: "metadata" in part ? part.metadata : undefined,
97119
}),
98120
)
121+
if (part.type === "tool") {
122+
await this.handleToolPart(session.id, part)
123+
}
99124
}
100125

101126
private async handlePartDelta(event: EventMessagePartDelta) {
@@ -181,6 +206,106 @@ export class Subscription {
181206
}),
182207
)
183208
}
209+
210+
private async handleToolPart(sessionId: string, part: ToolPart) {
211+
await this.toolStart(sessionId, part)
212+
213+
switch (part.state.status) {
214+
case "pending":
215+
this.shellSnapshots.delete(part.callID)
216+
return
217+
218+
case "running":
219+
await this.runningTool(sessionId, part)
220+
return
221+
222+
case "completed":
223+
this.clearTool(part.callID)
224+
await this.input.connection.sessionUpdate({
225+
sessionId,
226+
update: {
227+
sessionUpdate: "tool_call_update",
228+
...completedToolUpdate({
229+
toolCallId: part.callID,
230+
toolName: part.tool,
231+
state: part.state,
232+
}),
233+
},
234+
})
235+
return
236+
237+
case "error":
238+
this.clearTool(part.callID)
239+
await this.input.connection.sessionUpdate({
240+
sessionId,
241+
update: {
242+
sessionUpdate: "tool_call_update",
243+
...errorToolUpdate({
244+
toolCallId: part.callID,
245+
toolName: part.tool,
246+
state: part.state,
247+
}),
248+
},
249+
})
250+
return
251+
}
252+
}
253+
254+
private async runningTool(sessionId: string, part: ToolPart) {
255+
if (part.state.status !== "running") return
256+
257+
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
258+
if (output !== undefined) {
259+
if (this.shellSnapshots.get(part.callID) === output) {
260+
await this.input.connection.sessionUpdate({
261+
sessionId,
262+
update: {
263+
sessionUpdate: "tool_call_update",
264+
...duplicateRunningToolUpdate({
265+
toolCallId: part.callID,
266+
toolName: part.tool,
267+
state: part.state,
268+
}),
269+
},
270+
})
271+
return
272+
}
273+
this.shellSnapshots.set(part.callID, output)
274+
}
275+
276+
await this.input.connection.sessionUpdate({
277+
sessionId,
278+
update: {
279+
sessionUpdate: "tool_call_update",
280+
...runningToolUpdate({
281+
toolCallId: part.callID,
282+
toolName: part.tool,
283+
state: part.state,
284+
output,
285+
}),
286+
},
287+
})
288+
}
289+
290+
private async toolStart(sessionId: string, part: ToolPart) {
291+
if (this.toolStarts.has(part.callID)) return
292+
this.toolStarts.add(part.callID)
293+
await this.input.connection.sessionUpdate({
294+
sessionId,
295+
update: {
296+
sessionUpdate: "tool_call",
297+
...pendingToolCall({
298+
toolCallId: part.callID,
299+
toolName: part.tool,
300+
}),
301+
},
302+
})
303+
}
304+
305+
private clearTool(toolCallId: string) {
306+
this.toolStarts.delete(toolCallId)
307+
this.shellSnapshots.delete(toolCallId)
308+
}
184309
}
185310

186311
export * as ACPNextEvent from "./event"

0 commit comments

Comments
 (0)