mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 11:07:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d78bc5da |
@@ -752,7 +752,10 @@ const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"
|
||||
|
||||
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
|
||||
auth. `Generation.await` and `Generation.events` fail with a
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
|
||||
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
|
||||
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
|
||||
provider job, which keeps running and billing: call `cancel()` to stop it. Failed,
|
||||
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
|
||||
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
|
||||
a video is still returned and as a `ContentPolicy` reason when nothing is.
|
||||
|
||||
@@ -361,6 +361,10 @@ Poll = { interval?: Duration; timeout?: Duration }
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
|
||||
|
||||
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
|
||||
@@ -102,7 +102,7 @@ export class Generation<Response> {
|
||||
return settled.pipe(
|
||||
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
|
||||
Effect.flatMap((generation) => generation.result()),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,20 +123,7 @@ export class Generation<Response> {
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
|
||||
// race and schedule another zero-delay poll.
|
||||
const refresh = Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
now >= deadline
|
||||
? this.timeoutError(timeout)
|
||||
: this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const refresh = within(this.refresh(), this.id, timeout, deadline)
|
||||
const schedule = this.schedule(options?.poll).pipe(
|
||||
Schedule.modifyDelay((meta) =>
|
||||
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
|
||||
@@ -157,15 +144,6 @@ export class Generation<Response> {
|
||||
return { type: "generation-progress", id: this.id, progress: this.progress }
|
||||
}
|
||||
|
||||
private timeoutError(timeout: Duration.Duration) {
|
||||
return new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private poll(poll: Poll | undefined) {
|
||||
return this.refresh().pipe(
|
||||
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
|
||||
@@ -177,12 +155,53 @@ export class Generation<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
/** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */
|
||||
export const resultEvents = <Response, A>(
|
||||
generation: Generation<Response>,
|
||||
expand: (response: Response) => ReadonlyArray<A>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<Observation | A, AIError> =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), expand))),
|
||||
): Stream.Stream<Observation | A, AIError> => {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(
|
||||
Stream.fromIterableEffect(
|
||||
within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe(
|
||||
Effect.map(expand),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast
|
||||
* request could otherwise win the zero-budget race and schedule another zero-delay poll.
|
||||
*/
|
||||
const within = <A>(effect: Effect.Effect<A, AIError>, id: string, timeout: Duration.Duration, deadline: number) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
now >= deadline
|
||||
? Effect.fail(timeoutError(id, timeout))
|
||||
: effect.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => Effect.fail(timeoutError(id, timeout)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const timeoutError = (id: string, timeout: Duration.Duration) =>
|
||||
new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ export { ImageClient } from "./image-client.js"
|
||||
export { Auth } from "./route/auth.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { ProviderPackage } from "./provider-package.js"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js"
|
||||
export { isContextOverflow, isContextOverflowFailure, isRetryable } from "./provider-error.js"
|
||||
export type {
|
||||
RouteLanguageModelInput,
|
||||
RouteRoutedLanguageModelInput,
|
||||
|
||||
@@ -58,6 +58,47 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
/**
|
||||
* Whether a failed call may succeed when sent again: rate limits, provider-side failures, transport failures that did
|
||||
* not deliver an accepted write, and unrecognized failures. Callers decide which calls are safe to repeat.
|
||||
*/
|
||||
export const isRetryable = (error: AIError) => {
|
||||
const override = error.reason.http?.headers["x-should-retry"]
|
||||
if (override === "true") return true
|
||||
if (override === "false") return false
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
|
||||
// Read failures can still recover; the caller chooses retry versus continuation from durable output.
|
||||
case "Transport":
|
||||
return (
|
||||
error.reason.delivery !== "rejected" &&
|
||||
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
|
||||
)
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
// deterministic evidence, and transient failures are exactly the ones
|
||||
// that arrive in shapes no classifier anticipates.
|
||||
case "UnknownProvider":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidRequest":
|
||||
case "UnsupportedOperation":
|
||||
case "NoRoute":
|
||||
case "Timeout":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "./auth.js"
|
||||
import { Endpoint } from "./endpoint.js"
|
||||
@@ -7,6 +7,7 @@ import { RequestExecutor } from "./executor.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation, isTerminal } from "../generation.js"
|
||||
import type { Media } from "../media.js"
|
||||
import { isRetryable } from "../provider-error.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
@@ -137,6 +138,32 @@ export const inline = <Request extends MediaRequest, Response>(
|
||||
}
|
||||
}
|
||||
|
||||
const READ_RETRY_MAX_DELAY = Duration.seconds(30)
|
||||
|
||||
/**
|
||||
* Status and result reads retry transient failures; `start` and `cancel` never do. Gaps grow exponentially from 1s,
|
||||
* jittered, up to 30s each, for at most 8 retries (about two minutes when every attempt fails), so a direct
|
||||
* `Generation.result()` stays bounded; `await` and `events` also cut retries off at `poll.timeout`. A provider
|
||||
* `retryAfterMs` raises the gap, still capped at 30s.
|
||||
*/
|
||||
const READ_RETRY = Schedule.max([
|
||||
Schedule.min([Schedule.exponential("1 second"), Schedule.spaced(READ_RETRY_MAX_DELAY)]),
|
||||
Schedule.recurs(8),
|
||||
]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<AIError>(),
|
||||
Schedule.modifyDelay(({ input, duration }) =>
|
||||
Effect.succeed(
|
||||
Duration.min(
|
||||
input.reason._tag === "RateLimit" || input.reason._tag === "ProviderInternal"
|
||||
? Duration.max(duration, Duration.millis(input.reason.retryAfterMs ?? 0))
|
||||
: duration,
|
||||
READ_RETRY_MAX_DELAY,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Compose a queued media protocol the same way, adding `start`/`resume` handles whose polls reuse the route's auth,
|
||||
* deployment headers, and (for `start`) the request's `http` overlay. The token is decoded once at the boundary and
|
||||
@@ -154,6 +181,8 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
|
||||
const materialize = (asset: Media.Asset) =>
|
||||
asset.materialize().pipe(Effect.provideService(RequestExecutorService, { execute }))
|
||||
// Only the GET exchange retries: a decoded terminal failure (`output.ended`) can be a `ProviderInternal` too, and
|
||||
// re-reading it would spin until the caller's deadline.
|
||||
const poll = <A>(operation: {
|
||||
readonly path: (token: Token) => string
|
||||
readonly decode: (
|
||||
@@ -161,9 +190,10 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) => Effect.Effect<A, AIError>
|
||||
}) =>
|
||||
transport
|
||||
.call("GET", operation.path(token), http, execute)
|
||||
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
|
||||
transport.call("GET", operation.path(token), http, execute).pipe(
|
||||
Effect.retry({ schedule: READ_RETRY, while: isRetryable }),
|
||||
Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })),
|
||||
)
|
||||
const status = poll(protocol.status)
|
||||
const cancel = protocol.cancel
|
||||
const send =
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Media, Video, VideoClient, type GenerationEvent } from "../src/index.js"
|
||||
import { Fal, Google, Runway, XAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, json, observe, settle, type Call } from "./lib/http.js"
|
||||
import { dynamicResponse, json, observe, settle, type Call, type HandlerInput } from "./lib/http.js"
|
||||
|
||||
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
||||
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
@@ -839,6 +840,169 @@ describe("Video / Runway", () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transient read failures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / transient read failures", () => {
|
||||
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
|
||||
const succeeded = { id: "task_1", status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] }
|
||||
const failure = (input: HandlerInput, status: number, headers?: Record<string, string>) =>
|
||||
json(input, { error: `HTTP ${status}` }, { status, headers })
|
||||
const methods = (calls: ReadonlyArray<Call>) => calls.map((call) => call.method)
|
||||
|
||||
it.effect("retries a 503 status poll and a 503 result read, then returns the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* settle(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }),
|
||||
5,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
// 1: status fails, 2: status succeeds, 3: result fails, 4: result succeeds.
|
||||
if (nth === 1 || nth === 3) return failure(input, 503)
|
||||
return json(input, succeeded)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" })
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for a 429 retry-after before polling again", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const fiber = yield* Effect.forkChild(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
if (nth === 1) return failure(input, 429, { "retry-after": "10" })
|
||||
return json(input, succeeded)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* TestClock.adjust("9 seconds")
|
||||
expect(methods(calls)).toEqual(["POST", "GET"])
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(fiber)
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a 400 status poll without retrying", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 400)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(methods(calls)).toEqual(["POST", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops retrying at poll.timeout with a Timeout reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* settle(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
|
||||
Effect.flip,
|
||||
),
|
||||
6,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 503)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds a streamed result read's retries by poll.timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* settle(
|
||||
Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flip,
|
||||
),
|
||||
6,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
return nth === 1 ? json(input, succeeded) : failure(input, 503)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never retries a failed submit", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(layer((input) => observe(calls, input).pipe(Effect.map(() => failure(input, 503))))),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(methods(calls)).toEqual(["POST"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never retries a failed cancel", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model, prompt: "x" })
|
||||
return yield* generation.cancel().pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
if (call.method === "DELETE") return failure(input, 503)
|
||||
return json(input, { id: "task_1", status: "RUNNING" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "DELETE"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared queued behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -39,30 +39,6 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await expectSelectedFile(page, "gamma.ts")
|
||||
})
|
||||
|
||||
test("shows and restores last turn changes from the session diff", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.route(`**/api/session/${sessionA}/diff**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: [diff("src/delta.ts")] }),
|
||||
}),
|
||||
)
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
|
||||
await page.getByRole("button", { name: "Git changes" }).click()
|
||||
await page.getByRole("option", { name: "Last turn changes" }).click()
|
||||
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "delta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
|
||||
await expectSelectedFile(page, "delta.ts")
|
||||
})
|
||||
|
||||
for (const tab of ["Context", "Open file", "README.md"]) {
|
||||
test(`restores the selected ${tab} pane tab after switching sessions and reloading`, async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from ".
|
||||
import type { DiffStyle } from "./review-tab"
|
||||
|
||||
export type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
export function createSessionReview(input: {
|
||||
session: SessionModel
|
||||
@@ -63,13 +64,15 @@ export function createSessionReview(input: {
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
// Turn diffs compare session snapshots, which the server only captures in Git projects.
|
||||
if (project?.vcs === "git" && input.session.identity.sessionID()) list.push("turn")
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(
|
||||
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
const value = mode()
|
||||
return value === "git" || value === "branch" ? value : undefined
|
||||
})
|
||||
const vcsKey = createMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -87,28 +90,22 @@ export function createSessionReview(input: {
|
||||
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
|
||||
: mobileChanges(),
|
||||
)
|
||||
const turnKey = createMemo(() => [server.scope, "session-turn", input.session.identity.sessionID()] as const)
|
||||
const diffQuery = createQuery(() => {
|
||||
const value = mode()
|
||||
const sessionID = input.session.identity.sessionID()
|
||||
const turn = value === "turn"
|
||||
const vcsQuery = createQuery(() => {
|
||||
const value = vcsMode()
|
||||
return {
|
||||
queryKey: turn ? turnKey() : ([...vcsKey(), value] as const),
|
||||
queryKey: [...vcsKey(), value] as const,
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
|
||||
refetchOnMount: "always" as const,
|
||||
// A finished turn's diff is immutable and expensive, so only the idle transition refreshes it.
|
||||
refetchOnWindowFocus: !turn,
|
||||
queryFn: turn
|
||||
? sessionID
|
||||
? () => server.api.session.diff({ sessionID })
|
||||
: skipToken
|
||||
: () =>
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: value
|
||||
? () =>
|
||||
server.api.vcs
|
||||
.diff({
|
||||
location: { directory: location().directory },
|
||||
mode: value === "git" ? "working" : value,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
.then((result) => result.data)
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const detailsQuery = createQuery(() => ({
|
||||
@@ -139,13 +136,16 @@ export function createSessionReview(input: {
|
||||
on(
|
||||
() => input.screen.review.open() || mobileChanges(),
|
||||
(open, previous) => {
|
||||
if (!open || previous || !input.screen.files.open() || diffQuery.isFetching) return
|
||||
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
|
||||
refresh()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const diffs = () => (diffQuery.isFetched ? (diffQuery.data ?? []) : [])
|
||||
const diffs = () => {
|
||||
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||
return []
|
||||
}
|
||||
const activeFile = () => {
|
||||
const list = diffs()
|
||||
const selected = selectedFile()
|
||||
@@ -155,12 +155,17 @@ export function createSessionReview(input: {
|
||||
const count = () => diffs().length
|
||||
const hasChanges = () => count() > 0
|
||||
const ready = () => {
|
||||
// A project without VCS never enables diffQuery, so its status stays "pending" forever.
|
||||
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
|
||||
const project = input.session.project()
|
||||
if (project && !project.vcs) return true
|
||||
return !diffQuery.isPending
|
||||
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const value = vcsMode()
|
||||
if (!value) return undefined
|
||||
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
|
||||
const directory = reviewDiffDirectory(root, path)
|
||||
const source = diffs().find((diff) => diff.file === path)
|
||||
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
|
||||
if (!diff || !source) return undefined
|
||||
@@ -168,26 +173,6 @@ export function createSessionReview(input: {
|
||||
if (reviewDiffNeedsLoad(diff)) return undefined
|
||||
return diff
|
||||
}
|
||||
const value = mode()
|
||||
// Full-file patches past the server's output budget come back empty; bounded context usually fits.
|
||||
if (value === "turn") {
|
||||
const sessionID = input.session.identity.sessionID()
|
||||
if (!sessionID) return undefined
|
||||
return queryClient
|
||||
.fetchQuery({
|
||||
queryKey: [...turnKey(), "bounded", version] as const,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
queryFn: () => server.api.session.diff({ sessionID, context: 3 }),
|
||||
})
|
||||
.then((result) => valid(result.find((diff) => diff.file === path)))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load bounded turn diff", { path, error })
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
|
||||
const directory = reviewDiffDirectory(root, path)
|
||||
const request = (scope: string, context?: number) =>
|
||||
queryClient
|
||||
.fetchQuery({
|
||||
@@ -372,7 +357,6 @@ export function createSessionReview(input: {
|
||||
(next, previous) => {
|
||||
if (next !== "idle" || previous === undefined || previous === "idle") return
|
||||
refresh()
|
||||
void queryClient.invalidateQueries({ queryKey: turnKey() })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -418,7 +402,7 @@ export function createSessionReview(input: {
|
||||
open: () => state.detailsOpen,
|
||||
setOpen: (open: boolean) => setState("detailsOpen", open),
|
||||
},
|
||||
diffVersion: () => diffQuery.dataUpdatedAt,
|
||||
diffVersion: () => vcsQuery.dataUpdatedAt,
|
||||
diffStyle: {
|
||||
current: layout.review.diffStyle,
|
||||
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
|
||||
|
||||
@@ -252,6 +252,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
|
||||
|
||||
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
|
||||
@@ -260,7 +261,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!props.review.ready()}>
|
||||
<Match when={loading()}>
|
||||
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
@@ -284,10 +285,11 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
|
||||
|
||||
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!props.review.ready()}>
|
||||
<Match when={loading()}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry.js"
|
||||
|
||||
import { AIError, isContextOverflowFailure } from "@opencode/ai"
|
||||
import { AIError, isContextOverflowFailure, isRetryable } from "@opencode/ai"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
@@ -12,6 +12,8 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { toSessionError } from "../to-session-error.js"
|
||||
|
||||
export { isRetryable }
|
||||
|
||||
interface Input {
|
||||
readonly cause: AIError
|
||||
readonly error: SessionError.Error
|
||||
@@ -27,43 +29,6 @@ export interface Decision {
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
export function isRetryable(error: AIError) {
|
||||
const override = error.reason.http?.headers["x-should-retry"]
|
||||
if (override === "true") return true
|
||||
if (override === "false") return false
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
|
||||
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
|
||||
case "Transport":
|
||||
return (
|
||||
error.reason.delivery !== "rejected" &&
|
||||
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
|
||||
)
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
// deterministic evidence, and transient failures are exactly the ones
|
||||
// that arrive in shapes no classifier anticipates.
|
||||
case "UnknownProvider":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidRequest":
|
||||
case "UnsupportedOperation":
|
||||
case "NoRoute":
|
||||
case "Timeout":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */
|
||||
const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user