From df80096fb5fdecf18c7efa711fbe315c9b2a75ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 23:45:35 +0000 Subject: [PATCH 1/2] test(opencode): reproduce dropped compaction events from OpenCode 2.1 (#26193) These tests reproduce #26193: OpenCode 2.1+ omits data.recent on session.compaction.started/ended, so the bundled protocol schema rejects the frames and the events stream drops them. Co-Authored-By: Claude Opus 5.5 --- .../src/server/v2/OpenCode2Client.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index cee3372e14699..df9eebfbd57ef 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describe } from "vite-plus/test"; @@ -45,3 +46,70 @@ describe("OpenCode2Client authorization", () => { }), ); }); + +// Frames captured from OpenCode 2.1.4, which no longer sends `data.recent`. +const COMPACTION_FRAMES = [ + { + id: "evt_17fbf08bf0027kXxWvCuGCkaol", + created: 1793144588479, + type: "session.compaction.started", + location: { directory: "/tmp/proj" }, + data: { + sessionID: "ses_e80415922ffetjml4RUW7tRPQL", + reason: "manual", + inputID: "msg_17fbf08b10019CxokkaEaAaZ8f", + }, + durable: { aggregateID: "ses_e80415922ffetjml4RUW7tRPQL", seq: 16, version: 1 }, + }, + { + id: "evt_17fbf1179001YzrfiloDXDkGMV", + created: 1793144590713, + type: "session.compaction.ended", + location: { directory: "/tmp/proj" }, + data: { + sessionID: "ses_e80415922ffetjml4RUW7tRPQL", + reason: "manual", + model: { id: "claude-haiku-5-5", providerID: "anthropic", variant: "default" }, + text: "## Objective\n- (none)", + tokens: { input: 4, output: 140, reasoning: 0, cache: { read: 5113, write: 10821 } }, + cost: 0.001474155, + }, + durable: { aggregateID: "ses_e80415922ffetjml4RUW7tRPQL", seq: 18, version: 1 }, + }, +]; + +const layerEventStream = (frames: ReadonlyArray) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ), + ); + +describe("OpenCode2Client events", () => { + it.effect("delivers compaction events from OpenCode 2.1", () => + Effect.gen(function* () { + const opencode = yield* OpenCode2Client.OpenCode2Client; + const { events } = yield* opencode.connect({ + baseUrl: "http://127.0.0.1:4096", + password: "pw", + }); + const received = yield* Stream.runCollect(yield* events); + assert.deepStrictEqual( + received.map((event) => event.type), + ["session.compaction.started", "session.compaction.ended"], + ); + }).pipe( + Effect.provide( + OpenCode2Client.layer.pipe(Layer.provide(layerEventStream(COMPACTION_FRAMES))), + ), + ), + ); +}); From 6aae4aa4f4bacbd7b09ee7170003892fb37ef9ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 23:51:24 +0000 Subject: [PATCH 2/2] fix(opencode): compaction shows again on OpenCode 2.1+ (#26193) OpenCode 2.1 stopped sending data.recent on session.compaction.started and session.compaction.ended. The bundled 2.0.28 protocol schema still requires it, so both frames were rejected and silently dropped, and the thread never showed compaction. When an event fails to decode and is one of those two types without a recent key, decode it again with recent filled as an empty string. T3 never reads recent. Frames that decode today take the same path as before. Fixes pingdotgg/t3code#26193 Co-Authored-By: Claude Opus 5.5 --- .../src/server/v2/OpenCode2Client.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 79f6d5f917366..280e807e12d6f 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -76,6 +77,23 @@ export class OpenCode2Client extends Context.Service< const SILENT_STREAM_TIMEOUT = "45 seconds"; const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const decodeEventValue = Schema.decodeUnknownResult(OpenCodeEvent); + +// OpenCode 2.1+ omits data.recent on compaction events; the bundled 2.0 schema still requires it. +const decodeCompactionWithoutRecent = (data: string) => { + const frame = Option.getOrUndefined(decodeJson(data)); + if ( + !Predicate.isObject(frame) || + (frame.type !== "session.compaction.started" && frame.type !== "session.compaction.ended") || + !Predicate.isObject(frame.data) || + "recent" in frame.data + ) { + return Option.none(); + } + return Result.getSuccess(decodeEventValue({ ...frame, data: { ...frame.data, recent: "" } })); +}; + const decodeEnvelope = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -128,8 +146,12 @@ const readEvents = HttpClient.get("/api/event", { Stream.pipeThroughChannel(Sse.decode()), Stream.filterMapEffect((frame) => { const decoded = decodeEvent(frame.data); - return Result.isSuccess(decoded) - ? Effect.succeed(Result.succeed(decoded.success)) + if (Result.isSuccess(decoded)) { + return Effect.succeed(Result.succeed(decoded.success)); + } + const compaction = decodeCompactionWithoutRecent(frame.data); + return Option.isSome(compaction) + ? Effect.succeed(Result.succeed(compaction.value)) : undecodable(frame.data); }), ),