Compare commits

...

5 Commits

Author SHA1 Message Date
James Long b50cd706ce fix(core): gate durable event persistence 2026-08-04 23:35:14 +00:00
James Murdza f0afb6750e fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James Murdza 703d09f306 fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
opencode aefaf140c1 sync release versions for v1.18.13 2026-08-04 12:12:30 +00:00
Shoubhit Dash 44614c79c4 fix(acp): drain updates before end turn (#40422) 2026-08-04 17:26:33 +05:30
40 changed files with 390 additions and 126 deletions
+28 -28
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.12",
"version": "1.18.13",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.12",
"version": "1.18.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.12",
"version": "1.18.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -836,7 +836,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -849,7 +849,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -883,7 +883,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -902,7 +902,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -944,7 +944,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -971,7 +971,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1022,7 +1022,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.12",
"version": "1.18.13",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.12",
"version": "1.18.13",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.12",
"version": "1.18.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.12",
"version": "1.18.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+21 -15
View File
@@ -165,6 +165,7 @@ export const allBounded = (events: Interface, capacity: number) =>
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
readonly persistDurableEvents?: boolean
}
export const layerWith = (options?: LayerOptions) =>
@@ -180,6 +181,7 @@ export const layerWith = (options?: LayerOptions) =>
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const persistDurableEvents = options?.persistDurableEvents ?? true
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
@@ -333,19 +335,20 @@ export const layerWith = (options?: LayerOptions) =>
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
if (persistDurableEvents)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
@@ -634,5 +637,8 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
export const nodeWith = (options?: LayerOptions) =>
makeGlobalNode({ service: Service, layer: layerWith(options), deps: [Database.node] })
export const node = nodeWith()
export const sequenceOnlyNode = nodeWith({ persistDurableEvents: false })
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.12",
"version": "1.18.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.12",
"version": "1.18.13",
"name": "opencode",
"type": "module",
"license": "MIT",
+88 -9
View File
@@ -40,7 +40,10 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly connectionWaiters = new Set<() => void>()
private readonly idleWaiters = new Map<string, Set<ReturnType<typeof signal>>>()
private readonly permission: ACPPermission.Handler
private connected = false
private started = false
constructor(
@@ -63,10 +66,35 @@ export class Subscription {
stop() {
this.abort.abort()
this.disconnected()
for (const resolve of this.connectionWaiters) resolve()
this.connectionWaiters.clear()
}
async runUntilIdle<A>(sessionId: string, request: () => Promise<A>) {
await this.waitUntilConnected()
const waiter = signal()
const waiters = this.idleWaiters.get(sessionId) ?? new Set()
waiters.add(waiter)
this.idleWaiters.set(sessionId, waiters)
try {
// Idle is queued after the turn's events, and this subscription awaits each update in order.
void waiter.promise.catch(() => {})
const response = await request()
await waiter.promise
return response
} finally {
waiters.delete(waiter)
if (waiters.size === 0) this.idleWaiters.delete(sessionId)
}
}
async handle(event: Event) {
switch (event.type) {
case "session.status":
if (event.properties.status.type === "idle") this.idle(event.properties.sessionID)
return
case "permission.asked":
this.permission.handle(event)
return
@@ -115,19 +143,51 @@ export class Subscription {
private async run() {
while (!this.abort.signal.aborted) {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch(() => {})
}
await this.consume().catch(() => {})
this.disconnected()
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private async consume() {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
this.connected = true
for (const resolve of this.connectionWaiters) resolve()
this.connectionWaiters.clear()
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch(() => {})
}
}
private async waitUntilConnected() {
while (!this.connected) {
if (this.abort.signal.aborted) throw new Error("ACP event subscription stopped")
await new Promise<void>((resolve) => this.connectionWaiters.add(resolve))
}
}
private disconnected() {
if (!this.connected) return
this.connected = false
const error = new Error("ACP event stream disconnected")
for (const waiters of this.idleWaiters.values()) {
for (const waiter of waiters) waiter.reject(error)
}
this.idleWaiters.clear()
}
private idle(sessionId: string) {
const waiters = this.idleWaiters.get(sessionId)
if (!waiters) return
this.idleWaiters.delete(sessionId)
for (const waiter of waiters) waiter.resolve()
}
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sessionId = part.sessionID || event.properties.sessionID
@@ -339,4 +399,23 @@ export class Subscription {
}
}
function signal() {
const state: {
resolve: () => void
reject: (reason?: unknown) => void
} = {
resolve: () => {},
reject: () => {},
}
const promise = new Promise<void>((resolve, reject) => {
state.resolve = resolve
state.reject = reject
})
return {
promise,
resolve: () => state.resolve(),
reject: (reason?: unknown) => state.reject(reason),
}
}
export * as ACPEvent from "./event"
+39 -31
View File
@@ -88,6 +88,8 @@ export function make(input: {
? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const runUntilIdle = <A>(sessionId: string, fn: () => Promise<A>) =>
events ? events.runUntilIdle(sessionId, fn) : fn()
const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
@@ -504,19 +506,21 @@ export function make(input: {
if (!command) {
const response = yield* request(
() =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
runUntilIdle(current.id, () =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
{ throwOnError: true },
),
),
"session",
)
@@ -528,17 +532,19 @@ export function make(input: {
if (known) {
const response = yield* request(
() =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
runUntilIdle(current.id, () =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
),
"session",
)
@@ -549,14 +555,16 @@ export function make(input: {
if (command.name === "compact") {
yield* request(
() =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
runUntilIdle(current.id, () =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
),
),
"session",
)
@@ -51,6 +51,7 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilderV1 } from "./app-node-builder-v1"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -106,6 +107,7 @@ export const AppLayer = AppNodeBuilderV1.build(
ShareNext.node,
SessionShare.node,
]),
[[EventV2.node, EventV2.sequenceOnlyNode]],
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
@@ -97,6 +97,29 @@ export function http(
headers.delete("content-encoding")
headers.delete("content-length")
// An upstream 5xx from a remote workspace sandbox arrives here as an opaque
// status — its real cause (and log line) live only inside the sandbox. Buffer
// the small error body, log it locally so it shows up in the host's log, and
// forward it unchanged (preserving content-type so the client can still parse
// the structured error, e.g. its `ref`).
if (response.status >= 500) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
const contentType = response.headers["content-type"] ?? "application/json"
headers.delete("content-type")
yield* Effect.logError("workspace proxy upstream error", {
url: url.toString(),
method: request.method,
status: response.status,
body: body.slice(0, 2000),
})
return HttpServerResponse.text(body, {
status: response.status,
statusText: statusText(response),
headers,
contentType,
})
}
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
statusText: statusText(response),
@@ -270,8 +270,10 @@ const app = LayerNode.group([
export function createRoutes(
corsOptions?: CorsOptions,
persistDurableEvents = false,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
const locationServiceMapV2 = buildLocationServiceMap()
const eventNode = persistDurableEvents ? EventV2.node : EventV2.sequenceOnlyNode
return Layer.mergeAll(
rootApiRoutes,
@@ -288,7 +290,10 @@ export function createRoutes(
corsVaryFix,
fenceLayer,
cors(corsOptions),
AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
AppNodeBuilderV1.build(MoveSession.node, [
[LocationServiceMap.node, locationServiceMapV2],
[EventV2.node, eventNode],
]),
HttpServer.layerServices,
]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
@@ -299,11 +304,12 @@ export function createRoutes(
AppNodeBuilderV1.build(SessionV2.node, [
[LocationServiceMap.node, locationServiceMapV2],
[SessionExecution.node, SessionExecutionLocal.node],
[EventV2.node, eventNode],
]),
),
Layer.provide(locationServiceMapV2),
Layer.provide(AppNodeBuilderV1.build(app)),
Layer.provide(AppNodeBuilderV1.build(app, [[EventV2.node, eventNode]])),
// Must stay last: layers provided later in this pipe build beneath earlier ones,
// so Observability must come after every service graph. Otherwise eagerly forked
// fibers (e.g. the ModelsDev background refresh) capture Effect's default stdout
@@ -34,5 +34,12 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) {
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
// The `directory` param is the *host's* working directory (e.g. a Windows
// path like `F:\proj`). It is meaningless — and dangerous — on the remote:
// the sandbox would `path.resolve` it against its own cwd, producing a bogus
// path like `/home/daytona/workspace/repo/F:\proj` that does not exist and
// crashes prompt handling. Drop it so the remote falls back to its own
// project root. This mirrors ProxyUtil.headers stripping `x-opencode-directory`.
proxyURL.searchParams.delete("directory")
return proxyURL
}
@@ -10,7 +10,7 @@ import type {
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
@@ -24,6 +24,54 @@ const modelID = ModelV2.ID.make("test-model")
const configuredModelID = ModelV2.ID.make("configured-model")
const secondModelID = ModelV2.ID.make("second-model")
function createEventStream() {
const queue: Event[] = []
const waiters: Array<(event: Event | undefined) => void> = []
const push = (event: Event) => {
const waiter = waiters.shift()
if (waiter) return waiter(event)
queue.push(event)
}
const stream = async function* (signal?: AbortSignal) {
while (!signal?.aborted) {
const event = queue.shift()
if (event) {
yield { payload: event }
continue
}
const next = await new Promise<Event | undefined>((resolve) => {
waiters.push(resolve)
signal?.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!next) return
yield { payload: next }
}
}
return { push, stream }
}
function idleEvent(sessionID: string): Event {
return {
id: `evt_idle_${sessionID}`,
type: "session.status",
properties: {
sessionID,
status: { type: "idle" },
},
}
}
function deferred<A>() {
const state: { resolve?: (value: A) => void } = {}
const promise = new Promise<A>((resolve) => {
state.resolve = resolve
})
return {
promise,
resolve: (value: A) => state.resolve?.(value),
}
}
const provider: Provider.Info = {
id: providerID,
name: "Test",
@@ -147,6 +195,7 @@ describe("ACP service sessions", () => {
options?: {
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
sessionUpdate?: (update: SessionNotification) => Promise<void>
},
) => {
const updates: SessionNotification[] = []
@@ -157,6 +206,7 @@ describe("ACP service sessions", () => {
const commands: unknown[] = []
const summarizes: unknown[] = []
const usageUpdates: string[] = []
const events = createEventStream()
const sessions = Array.from({ length: 102 }, (_, index) => ({
id: `ses_${index + 1}`,
directory: index % 2 === 0 ? "/workspace" : "/other",
@@ -164,6 +214,9 @@ describe("ACP service sessions", () => {
time: { created: index + 1, updated: index + 1 },
}))
const sdk = {
global: {
event: (input?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(input?.signal) }),
},
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
@@ -196,11 +249,9 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt:
options?.prompt ??
((input: unknown) => {
prompts.push(input)
return Promise.resolve({
prompt: async (input: { sessionID: string }) => {
const response = await (options?.prompt?.(input) ??
Promise.resolve({
data: {
info: assistantInfo({
input: 100,
@@ -209,10 +260,14 @@ describe("ACP service sessions", () => {
cache: { read: 11, write: 13 },
}),
},
})
}),
command: (input: unknown) => {
}))
prompts.push(input)
events.push(idleEvent(input.sessionID))
return response
},
command: (input: { sessionID: string }) => {
commands.push(input)
events.push(idleEvent(input.sessionID))
return Promise.resolve({
data: {
info: assistantInfo({
@@ -224,8 +279,9 @@ describe("ACP service sessions", () => {
},
})
},
summarize: (input: unknown) => {
summarize: (input: { sessionID: string }) => {
summarizes.push(input)
events.push(idleEvent(input.sessionID))
return Promise.resolve({ data: true })
},
abort:
@@ -249,7 +305,7 @@ describe("ACP service sessions", () => {
const connection = {
sessionUpdate: (update: SessionNotification) => {
updates.push(update)
return Promise.resolve()
return options?.sessionUpdate?.(update) ?? Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
const usage = UsageService.Service.of({
@@ -273,6 +329,7 @@ describe("ACP service sessions", () => {
commands,
summarizes,
usageUpdates,
events,
}
}
@@ -1018,6 +1075,75 @@ describe("ACP service sessions", () => {
expect(usageUpdates).toEqual([session.sessionId])
})
it("waits for queued session updates before returning end_turn", async () => {
const called = deferred<void>()
const response = deferred<{ data: { info: ReturnType<typeof assistantInfo> } }>()
const update = deferred<void>()
const release = deferred<void>()
const order: string[] = []
const fixture = makeService([], {
prompt: () => {
called.resolve(undefined)
return response.promise
},
sessionUpdate: (notification) => {
if (notification.update.sessionUpdate !== "agent_thought_chunk") return Promise.resolve()
update.resolve(undefined)
return release.promise.then(() => {
order.push("update")
})
},
})
const session = await Effect.runPromise(fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = Effect.runPromise(
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
).then((value) => {
order.push("response")
return value
})
await called.promise
fixture.events.push({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID: session.sessionId,
time: Date.now(),
part: {
id: "part_reasoning",
sessionID: session.sessionId,
messageID: "msg_assistant",
type: "reasoning",
text: "",
time: { start: Date.now() },
},
},
})
fixture.events.push({
id: "evt_delta",
type: "message.part.delta",
properties: {
sessionID: session.sessionId,
messageID: "msg_assistant",
partID: "part_reasoning",
field: "text",
delta: "thinking",
},
})
response.resolve({
data: {
info: assistantInfo({ input: 1, output: 1, reasoning: 1, cache: { read: 0, write: 0 } }),
},
})
await update.promise
expect(order).toEqual([])
release.resolve(undefined)
expect((await result).stopReason).toBe("end_turn")
expect(order).toEqual(["update", "response"])
})
it("maps assistant prompt errors to request errors instead of end turn", async () => {
const { service } = makeService([], {
prompt: () =>
@@ -48,7 +48,7 @@ const appLayer = AppNodeBuilder.build(
[[InstanceStore.bootstrapNode, noopBootstrapLayer]],
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
HttpApiApp.createRoutes(undefined, true),
{
disableListenLog: true,
disableLogger: true,
@@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => {
expect(result.searchParams.get("keep")).toBe("yes")
})
test("strips the host directory param so the remote resolves its own root", () => {
const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes")
const result = workspaceProxyURL("http://remote:8080/base", url)
expect(result.searchParams.get("directory")).toBeNull()
expect(result.searchParams.get("keep")).toBe("yes")
})
test("preserves hash from request", () => {
const url = new URL("http://localhost/page#section")
const result = workspaceProxyURL("http://remote:8080", url)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.18.12",
"version": "1.18.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.12",
"version": "1.18.13",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.18.12",
"version": "1.18.13",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.18.12",
"version": "1.18.13",
"publisher": "sst-dev",
"repository": {
"type": "git",