Compare commits

..

4 Commits

Author SHA1 Message Date
James Long 537ce02685 test(core): simplify command layer wiring 2026-06-20 21:46:35 -04:00
James Long d3bbfff826 test(opencode): simplify message pagination layer wiring (#33157) 2026-06-20 21:44:37 -04:00
James Long 468f425e76 test(opencode): simplify session retry layer wiring (#33155) 2026-06-20 21:44:02 -04:00
James Long d59619fffd test(opencode): simplify git layer wiring (#33156) 2026-06-20 21:43:03 -04:00
21 changed files with 108 additions and 290 deletions
+2
View File
@@ -2,6 +2,7 @@ export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { LayerNode } from "./effect/layer-node"
import { ModelV2 } from "./model"
import { State } from "./state"
@@ -68,3 +69,4 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = LayerNode.make(layer, [])
+8 -3
View File
@@ -305,7 +305,14 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -320,8 +327,6 @@ export const layer = Layer.effect(
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasAssistantStarted())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -66,7 +66,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let providerFailed = false
let assistantSettled = false
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@@ -157,18 +156,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.flush()
})
const failAssistant = Effect.fnUntraced(function* (error: string) {
if (assistantSettled) return
yield* flushFragments()
assistantSettled = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: error },
})
})
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* startAssistant()
@@ -388,7 +375,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
case "step-finish":
yield* flush()
assistantSettled = true
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
@@ -402,7 +388,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "provider-error":
providerFailed = true
yield* failAssistant(event.message)
yield* flush()
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: event.message },
})
return
}
})
@@ -410,7 +402,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return {
publish,
flush,
failAssistant,
failUnsettledTools,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
@@ -75,7 +75,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
if (item.type === "reasoning")
return sameModel
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
: item.text.trim().length > 0
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
+3 -2
View File
@@ -1,10 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -12,7 +13,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
const it = testEffect(LayerNode.buildLayer(LayerNode.group([CommandV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigCommandPlugin.Plugin", () => {
@@ -14,36 +14,6 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {
test("preserves projected assistant turns for request compilation", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
new SessionMessage.Assistant({
id: id(value),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content,
time: { created, completed: created },
})
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "text-empty", text: "" })]),
assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]),
assistant("reasoning", [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning",
text: "",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
]),
],
model,
)
expect(messages.map((message) => message.id)).toEqual([id("empty"), id("empty-text"), id("text"), id("reasoning")])
})
test("maps every top-level V2 Session message type", () => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
@@ -547,8 +547,6 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
{ type: "user", text: prompt },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
content: [
kind === "tool input"
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
+1 -37
View File
@@ -16,7 +16,6 @@ import {
HttpOptions,
LLMRequest,
LLMResponse,
Message,
Model,
ModelLimits,
LLMError as LLMErrorClass,
@@ -173,41 +172,6 @@ const resolveRequestOptions = (request: LLMRequest) =>
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
})
const meaningfulAssistantPart = (part: Message["content"][number]) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return (
part.text.trim().length > 0 ||
part.encrypted !== undefined ||
(part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
)
}
const omitEmptyAssistantMessages = (request: LLMRequest) => {
let changed = false
const messages = request.messages.flatMap((message) => {
if (message.role !== "assistant") return [message]
const content = message.content.filter(meaningfulAssistantPart)
if (content.length === 0 && (!message.native || Object.keys(message.native).length === 0)) {
changed = true
return []
}
if (content.length === message.content.length) return [message]
changed = true
return [
Message.make({
id: message.id,
role: message.role,
content,
metadata: message.metadata,
native: message.native,
}),
]
})
if (!changed) return request
return LLMRequest.update(request, { messages })
}
export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
@@ -371,7 +335,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(omitEmptyAssistantMessages(resolveRequestOptions(request)))
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
@@ -57,30 +57,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("removes empty assistant parts while preserving tool calls", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "" },
ToolCallPart.make({ id: "call_1", name: "read", input: { path: "README.md" } }),
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "read", input: { path: "README.md" } }],
},
])
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -92,48 +92,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty assistant turns before protocol serialization", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [Message.user("Before"), Message.assistant(""), Message.user("After")],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before" },
{ role: "user", content: "After" },
])
}),
)
it.effect("preserves opaque OpenAI-compatible reasoning continuation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [],
native: { openaiCompatible: { reasoning_content: "opaque reasoning" } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning_content: "opaque reasoning",
tool_calls: undefined,
},
])
}),
)
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+55 -18
View File
@@ -129,25 +129,62 @@ function normalizeMessages(
}
})
// Empty assistant turns carry no history and are rejected by several provider APIs.
msgs = msgs
.map((msg) => {
if (msg.role !== "assistant") return msg
if (typeof msg.content === "string") return msg.content !== "" ? msg : undefined
if (!Array.isArray(msg.content)) return msg
const content = msg.content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return (
part.text.trim().length > 0 ||
(part.providerOptions !== undefined && Object.keys(part.providerOptions).length > 0)
)
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text") {
return part.text !== ""
}
if (part.type === "reasoning") {
return (
part.text.trim().length > 0 ||
part.providerOptions?.anthropic?.signature != null ||
part.providerOptions?.anthropic?.redactedData != null
)
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
if (content.length === 0) return undefined
if (content.length === msg.content.length) return msg
return { ...msg, content }
})
.filter((msg): msg is ModelMessage => msg !== undefined)
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
// Bedrock specific transforms
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text") {
return part.text !== ""
}
if (part.type === "reasoning") {
return (
part.text.trim().length > 0 ||
part.providerOptions?.bedrock?.signature != null ||
part.providerOptions?.bedrock?.redactedData != null
)
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
if (model.api.id.includes("claude")) {
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
+16 -5
View File
@@ -22,6 +22,7 @@ import {
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { NotFoundError } from "@/storage/storage"
import { and } from "drizzle-orm"
import { desc } from "drizzle-orm"
@@ -38,6 +39,8 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect"
export const node = LayerNode.group([Database.node])
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
code: "ZlibError"
@@ -255,12 +258,16 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (msg.info.role === "assistant") {
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string; filename?: string }> = []
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
if (msg.info.error && !AbortedError.isInstance(msg.info.error)) continue
if (
msg.info.error &&
!(
AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
@@ -277,6 +284,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
// here is the only safe replay point we have.
// Use a single space so the separator survives replay without changing
// the neighboring signed reasoning blocks.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
@@ -950,7 +950,6 @@ export const layer = Layer.effect(
}
}
ctx.assistantMessage.error = error
ctx.assistantMessage.finish ??= "error"
yield* events.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID,
error: ctx.assistantMessage.error,
-1
View File
@@ -1259,7 +1259,6 @@ export const layer = Layer.effect(
providerID: msg.providerID,
aborted: true,
})
msg.finish = "error"
msg.time.completed = Date.now()
yield* sessions.updateMessage(msg)
})
+2 -1
View File
@@ -3,12 +3,13 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Git } from "../../src/git"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
const it = testEffect(Git.defaultLayer)
const it = testEffect(LayerNode.buildLayer(Git.node))
const scopedTmpdir = (options?: Parameters<typeof tmpdir>[0]) =>
Effect.acquireRelease(
@@ -2018,7 +2018,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" })
})
test("filters empty assistant content for non-anthropic providers", () => {
test("does not filter for non-anthropic providers", () => {
const openaiModel = {
...anthropicModel,
providerID: "openai",
@@ -2039,32 +2039,9 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, openaiModel, {})
expect(result).toStrictEqual([])
})
test("preserves metadata-only reasoning for non-anthropic providers", () => {
const openaiModel = {
...anthropicModel,
providerID: "openai",
api: { id: "gpt-5", url: "https://api.openai.com", npm: "@ai-sdk/openai" },
}
const reasoning = {
type: "reasoning",
text: "",
providerOptions: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted" } },
} as const
expect(
ProviderTransform.message([{ role: "assistant", content: [reasoning] }], openaiModel, {})[0]?.content,
).toContainEqual(
expect.objectContaining({
type: "reasoning",
text: "",
providerOptions: expect.objectContaining({
openai: expect.objectContaining({ reasoningEncryptedContent: "encrypted" }),
}),
}),
)
expect(result).toHaveLength(2)
expect(result[0].content).toBe("")
expect(result[1].content).toHaveLength(1)
})
test("leaves valid anthropic assistant tool ordering unchanged", () => {
@@ -988,7 +988,7 @@ describe("session.message-v2.toModelMessage", () => {
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("preserves aborted assistant partial output", async () => {
test("includes aborted assistant messages only when they have non-step-start/reasoning content", async () => {
const assistantID1 = "m-assistant-1"
const assistantID2 = "m-assistant-2"
@@ -1038,70 +1038,6 @@ describe("session.message-v2.toModelMessage", () => {
{ type: "text", text: "partial answer" },
],
},
{
role: "assistant",
content: [{ type: "reasoning", text: "thinking", providerOptions: undefined }],
},
])
})
test("preserves empty aborted assistant turns until the provider boundary", async () => {
const assistantID = "m-assistant"
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(
assistantID,
"m-parent",
new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"],
),
parts: [
{
...basePart(assistantID, "a1"),
type: "text",
text: "",
},
] as SessionV1.Part[],
},
]
const messages = await MessageV2.toModelMessages(input, model)
expect(messages).toStrictEqual([{ role: "assistant", content: [{ type: "text", text: "" }] }])
expect(ProviderTransform.message(messages, model, {})).toStrictEqual([])
})
test("preserves aborted assistant messages with signed reasoning", async () => {
const assistantID = "m-assistant"
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(
assistantID,
"m-parent",
new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"],
),
parts: [
{
...basePart(assistantID, "a1"),
type: "reasoning",
text: "",
time: { start: 0 },
metadata: { anthropic: { signature: "sig_1" } },
},
] as SessionV1.Part[],
},
]
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "",
providerOptions: { anthropic: { signature: "sig_1" } },
},
],
},
])
})
@@ -1356,7 +1292,6 @@ describe("session.message-v2.toModelMessage", () => {
expect(result).toHaveLength(2)
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
expect(ProviderTransform.message(result, model, {})).toEqual(result)
})
test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => {
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Layer, Option } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Effect, Option } from "effect"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
@@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer))
const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node])))
const withSession = <A, E, R>(
fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect<A, E, R>,
@@ -899,13 +899,9 @@ it.live("session.processor effect tests record aborted errors and idle state", (
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}
expect(handle.message.error?.name).toBe("MessageAbortedError")
expect(handle.message.finish).toBe("end_turn")
expect(handle.message.time.completed).toBeNumber()
expect(stored.info.role).toBe("assistant")
if (stored.info.role === "assistant") {
expect(stored.info.error?.name).toBe("MessageAbortedError")
expect(stored.info.finish).toBe("end_turn")
expect(stored.info.time.completed).toBeNumber()
}
expect(state).toMatchObject({ type: "idle" })
expect(errs).toContain("MessageAbortedError")
@@ -961,13 +957,9 @@ it.live("session.processor effect tests mark interruptions aborted without manua
expect(Exit.isFailure(exit)).toBe(true)
expect(handle.message.error?.name).toBe("MessageAbortedError")
expect(handle.message.finish).toBe("end_turn")
expect(handle.message.time.completed).toBeNumber()
expect(stored.info.role).toBe("assistant")
if (stored.info.role === "assistant") {
expect(stored.info.error?.name).toBe("MessageAbortedError")
expect(stored.info.finish).toBe("end_turn")
expect(stored.info.time.completed).toBeNumber()
}
expect(state).toMatchObject({ type: "idle" })
}),
@@ -1082,7 +1082,7 @@ raceNoLLMServer.instance(
expect(firstInterrupted?.info.role).toBe("assistant")
expect(firstInterrupted?.parts).toHaveLength(0)
if (firstInterrupted?.info.role === "assistant") {
expect(firstInterrupted.info.finish).toBe("error")
expect(firstInterrupted.info.finish).toBeUndefined()
expect(firstInterrupted.info.time.completed).toBeNumber()
expect(firstInterrupted.info.error?.name).toBe("MessageAbortedError")
}
+3 -2
View File
@@ -3,7 +3,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Layer, Schedule, Schema } from "effect"
import { Effect, Schedule, Schema } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -15,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
const providerID = ProviderV2.ID.make("test")
const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node])))
function apiError(headers?: Record<string, string>): SessionV1.APIError {
return Schema.decodeUnknownSync(SessionV1.APIError.Schema)(