mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 01:18:50 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6db1d6d6 | ||
|
|
b9431ea22e | ||
|
|
abf71fdb4e |
@@ -20,7 +20,6 @@ import {
|
||||
LanguageModel,
|
||||
LanguageModelLimits,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -232,17 +231,6 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
const incompleteStreamError = (route: string) =>
|
||||
new AIError({
|
||||
module: "LLMClient",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
route,
|
||||
}),
|
||||
})
|
||||
|
||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||
Stream.suspend(() => {
|
||||
let terminal = false
|
||||
@@ -259,7 +247,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -428,7 +416,10 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
|
||||
return yield* ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
"Provider stream ended without a terminal finish event",
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||
|
||||
@@ -105,7 +105,6 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
|
||||
@@ -133,8 +133,8 @@ describe("llm route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -538,8 +538,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
message: "Provider stream ended without a terminal finish event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1136,12 +1136,9 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(streamError.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
})
|
||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
|
||||
resources: ["README.md"],
|
||||
save: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
source: { type: "tool", messageID: "message-1", id: "call-1" },
|
||||
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||
}),
|
||||
).toEqual({
|
||||
id: "permission-1",
|
||||
|
||||
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
|
||||
always: input.save ?? [],
|
||||
metadata: input.metadata ?? {},
|
||||
tool:
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,24 +21,12 @@ describe("adaptServerEvent", () => {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
permission: "read",
|
||||
patterns: ["src/**"],
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
},
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
})
|
||||
@@ -82,26 +70,6 @@ describe("coalesceServerEvents", () => {
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, delta: string) =>
|
||||
adaptServerEvent({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
metadata: event.data.metadata ?? {},
|
||||
tool:
|
||||
event.data.source?.type === "tool"
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.id }
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
id: "call_1",
|
||||
callID: "call_1",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
|
||||
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? {
|
||||
...tool,
|
||||
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
|
||||
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.step.failed") {
|
||||
if (
|
||||
input.compatibility === "v1" &&
|
||||
event.data.error.message === "The provider response ended unexpectedly."
|
||||
event.data.error.message === "Provider stream ended without a terminal finish event"
|
||||
) {
|
||||
pendingStep = undefined
|
||||
v1InvalidOutput = true
|
||||
|
||||
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
|
||||
turn: (messageID) => [
|
||||
prompted(messageID),
|
||||
stepStarted(),
|
||||
stepFailed("The provider response ended unexpectedly."),
|
||||
executionFailed("The provider response ended unexpectedly."),
|
||||
stepFailed("Provider stream ended without a terminal finish event"),
|
||||
executionFailed("Provider stream ended without a terminal finish event"),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -20,11 +20,10 @@ export function isRetryable(error: AIError) {
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
return true
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AIError, ToolFailure } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import { Permission } from "../permission"
|
||||
import { Question } from "../question"
|
||||
import { Integration } from "../integration"
|
||||
@@ -40,10 +41,11 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
// with no message must not erase the tool's curated failure message.
|
||||
if (cause.error instanceof PlatformError)
|
||||
return { type: "tool.execution", message: `${cause.message}: ${platformErrorMessage(cause.error)}` }
|
||||
const unwrapped = toSessionError(cause.error)
|
||||
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
|
||||
if (unwrapped.message === "") return { ...unwrapped, type: "tool.execution", message: cause.message }
|
||||
return unwrapped
|
||||
}
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
@@ -56,6 +58,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
if (cause instanceof PlatformError) return { type: "unknown", message: platformErrorMessage(cause) }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
@@ -64,3 +67,39 @@ function providerError(type: string, reason: AIError["reason"]): SessionError.Er
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
function platformErrorMessage(error: PlatformError) {
|
||||
const reason = error.reason
|
||||
if (reason._tag === "BadArgument")
|
||||
return `invalid argument${reason.description ? `: ${reason.description}` : ""}`
|
||||
|
||||
const label = (() => {
|
||||
switch (reason._tag) {
|
||||
case "AlreadyExists":
|
||||
return "already exists"
|
||||
case "BadResource":
|
||||
return "resource is invalid or closed"
|
||||
case "Busy":
|
||||
return "resource is busy"
|
||||
case "InvalidData":
|
||||
return "invalid data"
|
||||
case "NotFound":
|
||||
return "not found"
|
||||
case "PermissionDenied":
|
||||
return "permission denied"
|
||||
case "TimedOut":
|
||||
return "timed out"
|
||||
case "UnexpectedEof":
|
||||
return "unexpected end of input"
|
||||
case "Unknown":
|
||||
return "system error"
|
||||
case "WouldBlock":
|
||||
return "would block"
|
||||
case "WriteZero":
|
||||
return "wrote zero bytes"
|
||||
}
|
||||
})()
|
||||
const target = reason.pathOrDescriptor === undefined ? "" : `: ${reason.pathOrDescriptor}`
|
||||
const description = reason.description === undefined ? "" : ` (${reason.description})`
|
||||
return `${label}${target}${description}`
|
||||
}
|
||||
|
||||
@@ -176,7 +176,12 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Working directory does not exist: ${target.canonical}` })),
|
||||
),
|
||||
)
|
||||
if (workdir.type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
@@ -280,7 +285,10 @@ export const Plugin = {
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
(error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { badArgument, systemError } from "effect/PlatformError"
|
||||
|
||||
const llm = (reason: AIError["reason"]) => new AIError({ module: "test", method: "stream", reason })
|
||||
|
||||
@@ -72,6 +73,26 @@ describe("toSessionError", () => {
|
||||
type: "tool.execution",
|
||||
message: "failed",
|
||||
})
|
||||
expect(
|
||||
toSessionError(
|
||||
new ToolFailure({
|
||||
message: "Unable to execute command: pwd",
|
||||
error: systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "stat",
|
||||
pathOrDescriptor: "/missing",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "tool.execution",
|
||||
message: "Unable to execute command: pwd: not found: /missing",
|
||||
})
|
||||
expect(toSessionError(new ToolFailure({ message: "Tool failed", error: new Error("") }))).toEqual({
|
||||
type: "tool.execution",
|
||||
message: "Tool failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
@@ -91,6 +112,31 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("formats platform errors for the model", () => {
|
||||
const missing = systemError({
|
||||
_tag: "NotFound",
|
||||
module: "FileSystem",
|
||||
method: "operation",
|
||||
pathOrDescriptor: "/target",
|
||||
description: "OS detail",
|
||||
})
|
||||
expect(toSessionError(missing)).toEqual({
|
||||
type: "unknown",
|
||||
message: "not found: /target (OS detail)",
|
||||
})
|
||||
expect(
|
||||
toSessionError(
|
||||
new ToolFailure({
|
||||
message: "Tool failed",
|
||||
error: badArgument({ module: "FileSystem", method: "operation", description: "invalid path" }),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "tool.execution",
|
||||
message: "Tool failed: invalid argument: invalid path",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -513,16 +513,6 @@ const providerUnavailable = () =>
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
|
||||
const incompleteStream = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended unexpectedly.",
|
||||
}),
|
||||
})
|
||||
|
||||
const invalidRequest = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
@@ -3959,26 +3949,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incomplete stream before output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry incomplete stream")
|
||||
yield* TestLLM.push(Stream.fail(incompleteStream()))
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "incomplete-stream-success"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a larger provider retry-after delay", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3999,7 +3969,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("does not retry eligible failures after observable output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const failure = incompleteStream()
|
||||
const failure = rateLimited()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
@@ -4017,7 +3987,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.invalid-output" },
|
||||
error: { type: "provider.rate-limit" },
|
||||
content: [{ type: "text", text: "Partial" }],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -257,6 +257,31 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a missing workdir", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: "missing" })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() =>
|
||||
expect(settled).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `Working directory does not exist: ${path.join(tmp.path, "missing")}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
Reference in New Issue
Block a user