Skip to content
Merged
109 changes: 109 additions & 0 deletions packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { assert, it } from "@effect/vitest";
import { Session } from "@opencode/client/effect";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import { describe } from "vite-plus/test";
Expand Down Expand Up @@ -46,3 +48,110 @@ describe("OpenCode2Client authorization", () => {
}),
);
});

describe("OpenCode2Client message history", () => {
it.effect.each([
{ status: "running", recentLocation: "metadata" },
{ status: "completed", recentLocation: "metadata" },
{ status: "running", recentLocation: "top-level" },
{ status: "completed", recentLocation: "top-level" },
] as const)(
"decodes $status compaction with $recentLocation recent",
({ status, recentLocation }) =>
Effect.gen(function* () {
const previous = {
id: "msg_repro088",
type: "synthetic",
text: "Fixture message 88",
time: { created: 1088 },
};
const compaction = {
type: "compaction",
id: "msg_repro089",
time: { created: 1089 },
status,
reason: "manual",
summary: "Fixture compaction",
...(recentLocation === "metadata"
? { metadata: { recent: previous.id } }
: { recent: previous.id }),
};
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
assert.include(request.url, "/api/session/ses_repro/message");
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify({ data: [previous, compaction], cursor: {} }), {
headers: { "content-type": "application/json" },
}),
);
}),
),
);
const opencode = yield* OpenCode2Client.OpenCode2Client.pipe(
Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))),
);
const { client } = yield* opencode.connect({
baseUrl: "http://127.0.0.1:4096",
password: "test-password",
});
const history = yield* client.message.list({
sessionID: Session.ID.make("ses_repro"),
order: "asc",
});

assert.deepStrictEqual(
history.data.map((message) => message.id),
[previous.id, compaction.id],
);
assert.deepInclude(history.data[1], {
type: "compaction",
status,
summary: compaction.summary,
});
}),
);

it.effect("keeps a compaction without any recent an error", () =>
Effect.gen(function* () {
const compaction = {
type: "compaction",
id: "msg_repro089",
time: { created: 1089 },
status: "completed",
reason: "manual",
summary: "Fixture compaction",
metadata: {},
};
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify({ data: [compaction], cursor: {} }), {
headers: { "content-type": "application/json" },
}),
),
),
),
);
const opencode = yield* OpenCode2Client.OpenCode2Client.pipe(
Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))),
);
const { client } = yield* opencode.connect({
baseUrl: "http://127.0.0.1:4096",
password: "test-password",
});
const error = yield* client.message
.list({ sessionID: Session.ID.make("ses_repro"), order: "asc" })
.pipe(Effect.flip);

assert.strictEqual(error._tag, "ClientError");
assert.isTrue(Schema.isSchemaError(error.cause));
assert.include(String(error.cause), "recent");
}),
);
});
57 changes: 56 additions & 1 deletion packages/provider-opencode/src/server/v2/OpenCode2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,61 @@ const basicAuthorization = (password: string | Redacted.Redacted) => {
return `Basic ${Buffer.from(`${OPENCODE_USERNAME}:${plain}`, "utf8").toString("base64")}`;
};

const MESSAGE_LIST_URL = /\/api\/session\/[^/?#]+\/message(?:[?#]|$)/;

/** Reads only the page's shape; each message stays opaque for the real schema to judge. */
const MessagePage = Schema.fromJsonString(
Schema.StructWithRest(Schema.Struct({ data: Schema.Array(Schema.Unknown) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
);
const decodePage = Schema.decodeUnknownOption(MessagePage);
const encodePage = Schema.encodeSync(MessagePage);

const LegacyCompaction = Schema.Struct({
type: Schema.Literal("compaction"),
metadata: Schema.Struct({ recent: Schema.String }),
});
const isLegacyCompaction = Schema.is(LegacyCompaction);

const liftCompactionRecent = (message: unknown) =>
isLegacyCompaction(message) && !("recent" in message)
? { ...message, recent: message.metadata.recent }
: message;

/**
* OpenCode 2.1 moved a compaction's `recent` into `metadata`, while the
* bundled 2.0.28 schema still requires it at the top level. Lifting it keeps
* history pages from either server decodable; any other shape still fails
* downstream as before.
*/
const liftCompactionRecents = HttpClient.transformResponse(
Effect.fnUntraced(function* (
pending: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
) {
const response = yield* pending;
const { request } = response;
if (
request.method !== "GET" ||
response.status !== 200 ||
!MESSAGE_LIST_URL.test(request.url)
) {
return response;
}
const text = yield* response.text;
const page = decodePage(text);
// The response caches its body, so the untouched one still decodes downstream.
if (Option.isNone(page)) return response;
const lifted = encodePage({ ...page.value, data: page.value.data.map(liftCompactionRecent) });
// The lifted body is longer than the one the server measured.
const { "content-length": _, ...headers } = response.headers;
return HttpClientResponse.fromWeb(
request,
new Response(lifted, { status: response.status, headers }),
);
}),
);

/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
Expand All @@ -161,7 +216,7 @@ export const make = Effect.gen(function* () {
HttpClientRequest.setHeader("Authorization", basicAuthorization(password)),
);
return OpenCode.make({ baseUrl }).pipe(
Effect.provideService(HttpClient.HttpClient, authenticated),
Effect.provideService(HttpClient.HttpClient, liftCompactionRecents(authenticated)),
Effect.map((client) => ({
client,
events: readEvents.pipe(
Expand Down
Loading