mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:56:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b731bc19e2 |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/protocol": patch
|
||||
"@opencode-ai/server": patch
|
||||
---
|
||||
|
||||
Allow session wait and interrupt requests to reach process-local execution without booting the session's Location services. Preserve session validation errors, immediate interrupt acceptance, and waiting for execution cleanup to finish.
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
|
||||
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
|
||||
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
|
||||
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
|
||||
"x86_64-linux": "sha256-Q7BQ46mKePJtaKzhHxahIXy/pZczPmm5cQuBDrgd2Bc=",
|
||||
"aarch64-linux": "sha256-pqk4iUhXzEc4ei9zpeGpPjX7Q6pxH1K5rgotD5Wf91s=",
|
||||
"aarch64-darwin": "sha256-1q3mK5zLqQA0vz7KErDOkjeAnmsTReI0lhBJfIobC/E=",
|
||||
"x86_64-darwin": "sha256-dBMQ6tZxt5VjgWTZELHgPk6fVhBfNYfmY+AnQ3iJ88Q="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const layer = Layer.effect(
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
if (result._tag === "Complete") return
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
return decline ? Result.succeed(decline) : Result.fail(cause)
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunner from "./index.js"
|
||||
|
||||
import type { AIError } from "@opencode-ai/ai"
|
||||
import { Context, Data, Effect } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import type { Promotable } from "../inbox.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||
@@ -19,11 +19,9 @@ export type RunError =
|
||||
|
||||
export type Continuation = { readonly step: number }
|
||||
|
||||
export type DrainResult = Data.TaggedEnum<{
|
||||
Complete: {}
|
||||
Moved: { readonly continuation?: Continuation }
|
||||
}>
|
||||
export const DrainResult = Data.taggedEnum<DrainResult>()
|
||||
export type DrainResult =
|
||||
| { readonly type: "complete" }
|
||||
| { readonly type: "moved"; readonly continuation?: Continuation }
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { Cause, Config, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
|
||||
import {
|
||||
LLMClient,
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
Message,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { QuestionTool } from "../../tool/plugin/question.js"
|
||||
import { InstructionState } from "../instruction-state.js"
|
||||
import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
@@ -15,18 +26,100 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Continuation } from "./index.js"
|
||||
import { Service, type Continuation } from "./index.js"
|
||||
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform.js"
|
||||
import { StepFailedError } from "../error.js"
|
||||
import { toSessionError } from "../to-session-error.js"
|
||||
import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionStep } from "./step.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
import { Tool } from "../../tool.js"
|
||||
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||
Retry: { readonly step: number }
|
||||
Continue: {
|
||||
readonly cause: AIError
|
||||
readonly error: SessionRunnerRetry.RetryableFailure["error"]
|
||||
readonly step: number
|
||||
}
|
||||
RecoverFull: { readonly step: number }
|
||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||
}>
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
|
||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||
const isDecline = (
|
||||
error: SessionModelRequest.ExecuteError,
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput")
|
||||
return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
|
||||
* settles its own call and then aborts the step; a defect from a tool implementation
|
||||
* becomes a failed tool call the model can read; a typed infrastructure failure must
|
||||
* fail the assistant and then the drain.
|
||||
*/
|
||||
const classifyToolExits = (
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>, never>,
|
||||
calls: ReadonlyArray<ToolCall>,
|
||||
) => {
|
||||
// Exits align with calls by construction: one owned fiber per accepted local call.
|
||||
const exits = settled._tag === "Success" ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
exit._tag === "Failure"
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const causes =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
|
||||
// drain's error channel never carries a decline.
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
const reasons = cause.reasons.flatMap(
|
||||
(reason): Array<Cause.Reason<never>> =>
|
||||
Cause.isFailReason(reason)
|
||||
? isDecline(reason.error)
|
||||
? []
|
||||
: // A typed failure here broke the ExecuteError contract (the per-fiber
|
||||
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
|
||||
// keeps it from being dropped, which would leave its call unsettled forever.
|
||||
[Cause.makeDieReason(reason.error)]
|
||||
: [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
})
|
||||
.at(0)
|
||||
return {
|
||||
interrupted: causes.some(Cause.hasInterrupts),
|
||||
declines,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
@@ -34,15 +127,17 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const modelTransport = yield* SessionModelTransport.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const steps = yield* SessionStep.make
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
@@ -71,7 +166,10 @@ const layer = Layer.effect(
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||
*/
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
@@ -81,25 +179,30 @@ const layer = Layer.effect(
|
||||
let force = input.force
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
|
||||
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable)))
|
||||
return { type: "complete" as const }
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
// Scope gates input promotion, not a between-step control that is next in line.
|
||||
// Between-turn control items run under any drain scope: scope gates which user
|
||||
// input may promote, not whether admitted housekeeping runs. Steered control
|
||||
// items go ahead of any queued input; only a queue-delivered control item
|
||||
// parked behind a queued prompt is not the next eligible item.
|
||||
if (yield* runPendingCompaction(input.sessionID, "input")) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
return DrainResult.Complete()
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
if (result._tag === "Moved") return result
|
||||
if (result.type === "moved") return result
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** Work this drain may perform: scoped input, or a between-turn control item next in line. */
|
||||
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
|
||||
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
|
||||
if (promotable === "input") return false
|
||||
@@ -107,20 +210,31 @@ const layer = Layer.effect(
|
||||
return next?.type === "compaction" || next?.type === "move"
|
||||
})
|
||||
|
||||
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
|
||||
/**
|
||||
* Runs logical steps until no tool result or newly admitted steer requires another
|
||||
* model call. Queued inputs remain pending until the current model work reaches idle.
|
||||
*/
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation: Continuation | undefined,
|
||||
drainPromotable: SessionInbox.Promotable,
|
||||
) {
|
||||
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
// The drain admitted this work, so the first step always runs — even after a
|
||||
// control item consumed at this boundary (unlike drain's one-shot force).
|
||||
let first = true
|
||||
// Every boundary has the same shape: control items first, then one exit decision,
|
||||
// then the model. The turn continues only while the first step, a continuation, or
|
||||
// steer input is owed. Deciding after control items means consuming the last
|
||||
// steered compaction ends the turn instead of issuing an input-free model call.
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
|
||||
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
|
||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
first = false
|
||||
promotable = "steer"
|
||||
@@ -129,100 +243,391 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (
|
||||
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
|
||||
const runStep = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
step: number,
|
||||
) {
|
||||
// Minting message identity before any attempt lets retries resume the same durable
|
||||
// message. A compaction restart re-mints: the old message is stranded behind the new
|
||||
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
|
||||
const retry = yield* Schedule.toStepWithSleep(
|
||||
SessionRunnerRetry.schedule(bus, sessionID, () => assistantMessageID),
|
||||
)
|
||||
/**
|
||||
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
|
||||
* Step.Failed and fails once attempts are exhausted. The step loop performs
|
||||
* the retry itself on the next iteration.
|
||||
*/
|
||||
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
|
||||
retry(failure).pipe(
|
||||
Effect.as(CallOutcome.Retry({ step: failure.step })),
|
||||
Pull.catchDone(() =>
|
||||
bus
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
error: failure.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(failure.cause))),
|
||||
),
|
||||
)
|
||||
let currentPromotable: SessionInbox.Promotable | undefined = promotable
|
||||
let currentStep = step
|
||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||
let recoverOverflow = true
|
||||
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
// A blocked initial instruction baseline must leave admitted input pending.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = currentPromotable
|
||||
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
|
||||
: 0
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
currentStep = promoted > 0 ? 1 : currentStep
|
||||
currentPromotable = undefined
|
||||
const loaded = yield* context.load(selected)
|
||||
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
yield* diagnosePromptCache(sessionID, prepared.request)
|
||||
const outcome = yield* steps.attempt({
|
||||
const outcome = yield* callModel(
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
toolsDisabled: stepLimitReached,
|
||||
currentPromotable,
|
||||
currentStep,
|
||||
recoverOverflow,
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
})
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
|
||||
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
Effect.gen(function* () {
|
||||
if (outcome._tag === "Retry")
|
||||
yield* bus.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
|
||||
return yield* outcome.cause
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (outcome._tag === "Continue") {
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (outcome._tag === "Compacted") {
|
||||
recoverOverflow = false
|
||||
assistantMessageID,
|
||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||
if (outcome._tag === "Continue") {
|
||||
yield* retry(
|
||||
new SessionRunnerRetry.RetryableFailure({
|
||||
cause: outcome.cause,
|
||||
error: outcome.error,
|
||||
step: outcome.step,
|
||||
}),
|
||||
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
|
||||
})
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
recoverContinuation = false
|
||||
if (outcome._tag === "Restart") {
|
||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
if (outcome._tag === "RecoverFull") recoverContinuation = false
|
||||
// Neither a retry nor a compaction restart re-promotes input.
|
||||
currentPromotable = undefined
|
||||
currentStep = outcome.step
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Prepares and runs at most one model call, executes its local tools, and durably
|
||||
* settles the step. Compaction may instead request that the logical step restart.
|
||||
*/
|
||||
const callModel = Effect.fn("SessionRunner.callModel")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable | undefined,
|
||||
step: number,
|
||||
recoverOverflow: boolean,
|
||||
recoverContinuation: boolean,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
const { session, agent } = loaded
|
||||
const resolved = loaded.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, resolved }
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed")
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: agent.info,
|
||||
model: resolved,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
yield* diagnosePromptCache(session.id, prepared.request)
|
||||
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
|
||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return prepared.executeTool(input)
|
||||
}
|
||||
// Every local tool call forked here is owned until it reaches one durable settlement.
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(bus, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
providerMetadataKey: transcript.providerMetadataKey,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
||||
tokens: finish.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? startSnapshot === snapshot
|
||||
? []
|
||||
: yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: finish.finish,
|
||||
rawFinish: finish.rawFinish,
|
||||
providerState: finish.providerState,
|
||||
...stepUsage(finish),
|
||||
...end,
|
||||
})
|
||||
})
|
||||
|
||||
// Concurrent writers, no lock: the provider loop and each tool fiber publish
|
||||
// durable events unserialized. This is safe because every publisher method commits
|
||||
// its state marks synchronously before its first await (see publish-llm-event.ts),
|
||||
// every required event order is per-source (each source is one sequential fiber),
|
||||
// and a fiber's events are causally after its own Tool.Called: the fork happens
|
||||
// below that publish. Cross-source order is unconstrained; either interleaving is
|
||||
// a truthful history of concurrent work.
|
||||
//
|
||||
// The stream is defined here but runs inside the settlement mask below: publish each
|
||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||
// context-overflow provider error so settlement may recover it via compaction.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (
|
||||
LLMEvent.is.providerError(event) &&
|
||||
isContextOverflowFailure(event) &&
|
||||
!publisher.record().outputStarted
|
||||
) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
yield* publisher.publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
toolRuns.push({
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
call: event,
|
||||
// Progress is ephemeral, not durable history: nothing to order.
|
||||
progress: (update) => publisher.progress(event.id, update),
|
||||
}),
|
||||
).pipe(
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(publisher.flush()),
|
||||
)
|
||||
|
||||
// Settle: only the stream and the fiber joins are interruptible (restore); every
|
||||
// other line is protected so a started call always reaches one durable outcome.
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
|
||||
// Join every owned tool run first: await all exits, not just the first failure.
|
||||
// Afterwards no fiber is alive, settlement is the only writer, and the record
|
||||
// is final. A failed join means the waiting itself was interrupted, so the runs
|
||||
// we abandoned are interrupted before settlement closes them out.
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(
|
||||
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (joined._tag === "Failure") yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
compaction.enabled() &&
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
)
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error.
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
// A thrown LLM failure not already recorded as the provider error either
|
||||
// escapes as a scheduled retry or fails the assistant durably.
|
||||
const unknownFinish =
|
||||
stream._tag === "Success" && publisher.record().finish?.finish === "unknown"
|
||||
? new AIError({
|
||||
module: "session",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
|
||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!publisher.record().outputStarted
|
||||
)
|
||||
return CallOutcome.RecoverFull({ step: currentStep })
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||
!publisher.record().outputStarted
|
||||
) {
|
||||
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
|
||||
// Step.Started must be durable before the failure escapes.
|
||||
yield* publisher.startAssistant()
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
error: llmError,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
// Close every unsettled call with the reason it could not settle truthfully,
|
||||
// and fail the assistant when the step itself cannot complete. A declined call
|
||||
// settles with its own reason before the generic sweeps.
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
|
||||
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
}
|
||||
if (tools.failure !== undefined) {
|
||||
const error = toSessionError(Cause.squash(tools.failure))
|
||||
yield* publisher.failUnsettledTools(error)
|
||||
}
|
||||
// Local calls have joined, so the remaining sweeps only close hosted calls the
|
||||
// provider promised but never resolved.
|
||||
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
// A clean stream that still left hosted calls unresolved fails the step itself.
|
||||
if (stream._tag === "Success" && !publisher.record().providerFailed) {
|
||||
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
|
||||
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
|
||||
const record = publisher.record()
|
||||
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
|
||||
if (record.failure) {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* publisher.publishStepFailure({
|
||||
...(record.finish ? stepUsage(record.finish) : {}),
|
||||
...end,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
)
|
||||
return CallOutcome.Continue({
|
||||
cause: llmFailure,
|
||||
error: llmError,
|
||||
step: currentStep,
|
||||
})
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return CallOutcome.Completed({
|
||||
// A local call or malformed tool input requires another model step, unless
|
||||
// this step already exhausted the agent's allowance.
|
||||
needsContinuation:
|
||||
!stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
@@ -294,6 +699,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
/** Closes stale tool calls left active by an earlier interrupted drain. */
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Clock, Effect, Iterable } from "effect"
|
||||
import { Clock, Effect } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
@@ -39,7 +39,13 @@ export interface StepRecord {
|
||||
readonly providerState?: SessionMessage.ProviderState
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
readonly needsContinuation: boolean
|
||||
readonly calls: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly called: boolean
|
||||
readonly settled: boolean
|
||||
readonly providerExecuted: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
@@ -79,6 +85,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
|
||||
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
|
||||
const deltaBatchInterval = 100
|
||||
type ToolState = {
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly name: string
|
||||
called: boolean
|
||||
settled: boolean
|
||||
@@ -243,7 +250,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
text: value,
|
||||
})
|
||||
@@ -262,8 +269,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
readonly providerExecuted?: boolean
|
||||
}) {
|
||||
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
||||
yield* startAssistant()
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
const tool: ToolState = {
|
||||
assistantMessageID,
|
||||
name: event.name,
|
||||
called: false,
|
||||
settled: false,
|
||||
@@ -306,7 +314,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
tool.settled = true
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
@@ -325,7 +333,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
tool.settled = true
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
error,
|
||||
...failureSnapshot(tool, metadata),
|
||||
@@ -375,6 +383,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
|
||||
)
|
||||
|
||||
const assistantMessageIDForTool = (id: string) => {
|
||||
const tool = tools.get(id)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
@@ -442,7 +455,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
input: asRecord(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
@@ -468,7 +481,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
if (event.result.type === "error") {
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
error: { type: "tool.execution", message: stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
@@ -479,7 +492,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
content: hostedContent(event.result),
|
||||
executed,
|
||||
@@ -496,7 +509,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
tool.settled = true
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id: event.id,
|
||||
error:
|
||||
event.message === `Unknown tool: ${event.name}`
|
||||
@@ -538,7 +551,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
tool.progress = update
|
||||
yield* bus.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
metadata: update,
|
||||
})
|
||||
@@ -561,7 +574,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
content: [content[0], ...content.slice(1)],
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
@@ -586,12 +599,16 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
providerFailed,
|
||||
failure: stepFailure,
|
||||
finish: stepSettlement,
|
||||
needsContinuation: Iterable.some(
|
||||
tools.values(),
|
||||
(tool) => !tool.providerExecuted && (tool.called || tool.settled),
|
||||
),
|
||||
calls: Array.from(tools, ([id, tool]) => ({
|
||||
id,
|
||||
name: tool.name,
|
||||
called: tool.called,
|
||||
settled: tool.settled,
|
||||
providerExecuted: tool.providerExecuted,
|
||||
})),
|
||||
}),
|
||||
startAssistant,
|
||||
streamed,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ export * as SessionRunnerRetry from "./retry.js"
|
||||
|
||||
import { AIError } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Duration, Effect, Schedule } from "effect"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
|
||||
export interface Input {
|
||||
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||
readonly cause: AIError
|
||||
readonly error: SessionError.Error
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
}
|
||||
readonly step: number
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: AIError) {
|
||||
const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
|
||||
@@ -40,25 +40,29 @@ export function isRetryable(error: AIError) {
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (input: Input) => {
|
||||
if (input.cause.reason._tag === "RateLimit" || input.cause.reason._tag === "ProviderInternal")
|
||||
return input.cause.reason.retryAfterMs
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
export const schedule = (
|
||||
bus: Bus.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
assistantMessageID: () => SessionMessage.ID,
|
||||
) =>
|
||||
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<Input>(),
|
||||
Schedule.modifyDelay(({ input, duration: delay }) => {
|
||||
const minimum = retryAfter(input)
|
||||
Schedule.setInputType<RetryableFailure>(),
|
||||
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
|
||||
const minimum = retryAfter(failure)
|
||||
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
|
||||
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
bus.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
assistantMessageID: assistantMessageID(),
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
export * as SessionStep from "./step.js"
|
||||
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { Tool } from "../../tool.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { QuestionTool } from "../../tool/plugin/question.js"
|
||||
import { StepFailedError } from "../error.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { toSessionError } from "../to-session-error.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { SessionRunnerModel } from "./model.js"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event.js"
|
||||
import { SessionRunnerRetry } from "./retry.js"
|
||||
|
||||
export type Outcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean }
|
||||
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
RecoverFull: {}
|
||||
Compacted: {}
|
||||
}>
|
||||
const Outcome = Data.taggedEnum<Outcome>()
|
||||
|
||||
interface Input {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly prepared: SessionModelRequest.Prepared
|
||||
readonly toolsDisabled: boolean
|
||||
readonly recoverContinuation: boolean
|
||||
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
|
||||
readonly recoverOverflow: Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
|
||||
/** Captures Location-scoped dependencies without introducing another service or execution loop. */
|
||||
export const make = Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
|
||||
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(bus, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
agent: input.agent,
|
||||
model: input.model.ref,
|
||||
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const executeTool = (call: ToolCall) => {
|
||||
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return input.prepared.executeTool({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.assistantMessageID,
|
||||
call,
|
||||
progress: (update) => publisher.progress(call.id, update),
|
||||
})
|
||||
}
|
||||
|
||||
// Provider and tool fibers retain per-source order without a shared writer queue.
|
||||
// A local execution starts only after its Tool.Called publication completes.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
// Read to the end, not just the finish event, so the next request can reuse this response.
|
||||
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (
|
||||
LLMEvent.is.providerError(event) &&
|
||||
isContextOverflowFailure(event) &&
|
||||
!publisher.record().outputStarted
|
||||
) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
yield* publisher.publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
toolRuns.push({
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(executeTool(event)).pipe(
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(publisher.flush()),
|
||||
)
|
||||
|
||||
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
if (joined._tag === "Failure") yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
|
||||
if (
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(input.recoverOverflow))
|
||||
)
|
||||
return Outcome.Compacted()
|
||||
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
const recorded = publisher.record()
|
||||
const unknownFinish =
|
||||
stream._tag === "Success" && recorded.finish?.finish === "unknown"
|
||||
? new AIError({
|
||||
module: "session",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
|
||||
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
input.recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!recorded.outputStarted
|
||||
)
|
||||
return Outcome.RecoverFull()
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
|
||||
// Retry state projects onto the existing assistant, even before it has produced output.
|
||||
yield* publisher.startAssistant()
|
||||
return Outcome.Retry({ cause: llmFailure, error: llmError })
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
|
||||
const toolFailure = interrupted
|
||||
? TOOLS_INTERRUPTED
|
||||
: tools.failure !== undefined
|
||||
? toSessionError(Cause.squash(tools.failure))
|
||||
: recorded.providerFailed
|
||||
? TOOLS_INTERRUPTED
|
||||
: undefined
|
||||
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
|
||||
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
|
||||
// All local fibers have joined; only provider-hosted results can still be missing.
|
||||
if (llmError || (stream._tag === "Success" && !recorded.providerFailed)) {
|
||||
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
|
||||
const record = publisher.record()
|
||||
if (record.finish || record.failure) {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? startSnapshot === snapshot
|
||||
? []
|
||||
: yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const usage = record.finish
|
||||
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
|
||||
: undefined
|
||||
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
|
||||
if (record.finish && usage && !record.failure)
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: record.finish.finish,
|
||||
rawFinish: record.finish.rawFinish,
|
||||
providerState: record.finish.providerState,
|
||||
...usage,
|
||||
snapshot,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
)
|
||||
return Outcome.Continue({ cause: llmFailure, error: llmError })
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: !input.toolsDisabled && record.needsContinuation,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
return { attempt }
|
||||
})
|
||||
|
||||
const isDecline = (
|
||||
error: SessionModelRequest.ExecuteError,
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
return false
|
||||
}
|
||||
|
||||
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
|
||||
const classifyToolExits = (
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
|
||||
calls: ReadonlyArray<ToolCall>,
|
||||
) => {
|
||||
const exits = settled._tag === "Success" ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
exit._tag === "Failure"
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const causes =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
const reasons = cause.reasons.flatMap(
|
||||
(reason): Array<Cause.Reason<never>> =>
|
||||
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
})
|
||||
.at(0)
|
||||
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
|
||||
}
|
||||
@@ -271,12 +271,12 @@ function schemaMakeError(error: unknown) {
|
||||
}
|
||||
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
const validateNamespace = (namespace: string) =>
|
||||
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
|
||||
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new RegistrationError({
|
||||
|
||||
@@ -538,9 +538,7 @@ function buildExecution(
|
||||
const store = yield* SessionStore.Service
|
||||
const runner = Layer.succeed(
|
||||
SessionRunner.Service,
|
||||
SessionRunner.Service.of({
|
||||
drain: (input) => drain(input).pipe(Effect.as(SessionRunner.DrainResult.Complete())),
|
||||
}),
|
||||
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
|
||||
@@ -342,7 +342,7 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
|
||||
@@ -85,10 +85,8 @@ describe("Tool", () => {
|
||||
it.effect("rejects invalid and colliding normalized names", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
for (const name of ["", "x".repeat(65)]) {
|
||||
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
|
||||
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
|
||||
}
|
||||
const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
|
||||
expect(invalid.message).toBe("Invalid tool name: 123")
|
||||
|
||||
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
|
||||
Effect.flip,
|
||||
@@ -98,67 +96,6 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes native tools without requiring letter-leading names or namespace segments", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(
|
||||
service,
|
||||
{ "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() },
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: false })
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual([
|
||||
"-lookup",
|
||||
"123",
|
||||
"123__private_-tools_2d_get_scene",
|
||||
"2d_get_scene",
|
||||
"_lookup",
|
||||
"execute",
|
||||
])
|
||||
for (const name of ["2d_get_scene", "123", "_lookup", "-lookup", "123__private_-tools_2d_get_scene"]) {
|
||||
expect((yield* snapshot.execute(call(name))).output).toEqual({ text: name })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes Code Mode tools without requiring letter-leading names or namespace segments", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() })
|
||||
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: true })
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
|
||||
"-lookup",
|
||||
"123",
|
||||
"123._private.-tools.2d_get_scene",
|
||||
"2d_get_scene",
|
||||
"_lookup",
|
||||
])
|
||||
const result = yield* snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-nonletter-names",
|
||||
name: "execute",
|
||||
input: {
|
||||
code: `const results = await Promise.all([
|
||||
tools["2d_get_scene"]({ text: "digit" }),
|
||||
tools["123"]({ text: "numeric" }),
|
||||
tools._lookup({ text: "underscore" }),
|
||||
tools["-lookup"]({ text: "hyphen" }),
|
||||
tools["123"]._private["-tools"]["2d_get_scene"]({ text: "namespaced" }),
|
||||
]); return results.map(result => result.text).join(",");`,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.content).toEqual([{ type: "text", text: "digit,numeric,underscore,hyphen,namespaced" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates a registration batch before installing any tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -76,7 +76,7 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
@@ -419,7 +419,7 @@ const execution = Layer.effect(
|
||||
.drain({ sessionID, force, continuation })
|
||||
.pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result._tag === "Complete" ? Effect.void : drain(sessionID, false, result.continuation),
|
||||
result.type === "complete" ? Effect.void : drain(sessionID, false, result.continuation),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2926,41 +2926,25 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("consumes the full provider stream before recording its boundary and settling local tools", () =>
|
||||
it.effect("records the stream boundary before local tools complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "Echo this")
|
||||
const tail = yield* Deferred.make<void>()
|
||||
const complete = yield* Deferred.make<void>()
|
||||
const finished = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(
|
||||
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
|
||||
Stream.concat(
|
||||
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
|
||||
Stream.drain,
|
||||
),
|
||||
),
|
||||
Stream.onEnd(Deferred.succeed(finished, undefined)),
|
||||
),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.tool("call-streamed", "echo", { text: "hello" }), TestLLM.stop())
|
||||
const tools = yield* blockTools()
|
||||
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const streamed = yield* bus
|
||||
.subscribe(SessionEvent.Step.Streamed)
|
||||
.pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||
|
||||
yield* tools.started
|
||||
yield* Deferred.await(tail)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
|
||||
expect(requireAssistant(yield* session.context(sessionID)).time.completed).toBeUndefined()
|
||||
yield* Deferred.succeed(complete, undefined)
|
||||
yield* Fiber.join(streamed)
|
||||
expect(yield* Deferred.isDone(finished)).toBe(true)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
expect(assistant.time.streamed).toBeDefined()
|
||||
expect(assistant.time.completed).toBeUndefined()
|
||||
@@ -2968,10 +2952,6 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
|
||||
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
|
||||
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4285,24 +4265,16 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "Interrupt tool settlement")
|
||||
const tools = yield* blockTools()
|
||||
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
|
||||
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* Fiber.join(streamed)
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt tool settlement" },
|
||||
{
|
||||
@@ -4319,11 +4291,8 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
])
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes.filter((type) => type === "session.tool.failed.2")).toHaveLength(1)
|
||||
expect(eventTypes.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
|
||||
expect(eventTypes).toContain("session.step.failed.1")
|
||||
expect(eventTypes).not.toContain("session.step.ended.1")
|
||||
expect(eventTypes).not.toContain("session.retry.scheduled.1")
|
||||
expect(requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4603,30 +4572,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not start another physical attempt after interruption during retry backoff", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
yield* admit(session, "Interrupt retry backoff")
|
||||
yield* TestLLM.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
|
||||
const scheduled = yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Fiber.join(scheduled)
|
||||
yield* session.interrupt(sessionID)
|
||||
const exit = yield* Fiber.await(run)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect(requests).toHaveLength(1)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
|
||||
expect(events).not.toContain("session.synthetic.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("immediately rebuilds once after explicit continuation rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -4976,45 +4921,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares retry accounting and assistant identity across transparent retries and partial continuations", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const scheduled = yield* Queue.unbounded<SessionMessage.ID>()
|
||||
yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.runForEach((event) => Queue.offer(scheduled, event.data.assistantMessageID)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* admit(session, "Mix retry paths")
|
||||
const failure = incompleteStream()
|
||||
const partial = TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "mixed-partial" }),
|
||||
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
|
||||
)
|
||||
yield* TestLLM.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
const identities: SessionMessage.ID[] = []
|
||||
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
|
||||
identities.push(yield* Queue.take(scheduled))
|
||||
yield* TestClock.adjust(delay)
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(identities[0]).toBe(identities[1])
|
||||
expect(identities[2]).toBe(identities[3])
|
||||
expect(identities[0]).not.toBe(identities[2])
|
||||
const messages = yield* session.context(sessionID)
|
||||
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
|
||||
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
|
||||
const events = yield* recordedEventTypes(sessionID)
|
||||
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
|
||||
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops incomplete stream continuations after five total attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLM, LLMClient, LLMEvent } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStep } from "@opencode-ai/core/session/runner/step"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
]),
|
||||
TestLLM.layer(),
|
||||
),
|
||||
)
|
||||
|
||||
for (const finish of ["stop", "content-filter"] as const) {
|
||||
it.effect(`settles ${finish} with snapshot files and nonzero usage after its tool`, () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessionID = Session.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const start = Snapshot.ID.make("before")
|
||||
const end = Snapshot.ID.make("after")
|
||||
const files = [RelativePath.make("changed.ts")]
|
||||
let captures = 0
|
||||
const steps = yield* SessionStep.make.pipe(
|
||||
Effect.provideService(LLMClient.Service, llm.client),
|
||||
Effect.provide(
|
||||
Layer.mock(Snapshot.Service)({
|
||||
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
|
||||
files: (input) => {
|
||||
expect(input).toEqual({ from: start, to: end })
|
||||
return Effect.succeed(files)
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
|
||||
.run()
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
limit: { context: 100_000, output: 1_000 },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
yield* llm.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: finish },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
nonCachedInputTokens: 10,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
},
|
||||
LLMEvent.toolCall({ id: "call-test", name: "test", input: {} }),
|
||||
),
|
||||
)
|
||||
const result = yield* steps
|
||||
.attempt({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool" }),
|
||||
options: {},
|
||||
executeTool: () => Effect.succeed({ content: "Completed tool" }),
|
||||
},
|
||||
toolsDisabled: false,
|
||||
recoverContinuation: true,
|
||||
recoverOverflow: Effect.succeed(false),
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isSuccess(result)).toBe(finish === "stop")
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
expect(captures).toBe(2)
|
||||
const message = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.get()
|
||||
expect(message?.data).toMatchObject({
|
||||
finish,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
|
||||
snapshot: { start, end, files },
|
||||
content: [{ type: "tool", state: { status: "completed" } }],
|
||||
})
|
||||
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
|
||||
const events = yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
const types = events.map((event) => event.type)
|
||||
const terminal = finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
expect(types.filter((type) => type === terminal)).toHaveLength(1)
|
||||
expect(types.indexOf("session.tool.success.2")).toBeLessThan(types.indexOf(terminal))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { SessionValidationMiddleware } from "../middleware/session-validation.js"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
@@ -464,7 +463,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
})
|
||||
.middleware(SessionValidationMiddleware)
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.wait",
|
||||
@@ -672,7 +671,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}).annotate({ identifier: "SessionInterruptResponse" }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(SessionValidationMiddleware)
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.interrupt",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "../errors.js"
|
||||
|
||||
export class SessionValidationMiddleware extends HttpApiMiddleware.Service<SessionValidationMiddleware>()(
|
||||
"@opencode/HttpApiSessionValidation",
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
@@ -38,9 +38,9 @@ try {
|
||||
const archive = join(temporary, `${name}.tgz`)
|
||||
|
||||
if (pkg.dependencies) {
|
||||
const unpacked = Object.entries(pkg.dependencies)
|
||||
.filter(([dependency, version]) => version.startsWith("workspace:") && !archives.has(dependency))
|
||||
.map(([dependency]) => dependency)
|
||||
const unpacked = Object.keys(pkg.dependencies).filter(
|
||||
(dependency) => dependency.startsWith("@opencode-ai/") && !archives.has(dependency),
|
||||
)
|
||||
if (unpacked.length > 0)
|
||||
throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
|
||||
pkg.dependencies = Object.fromEntries(
|
||||
|
||||
@@ -444,109 +444,6 @@ it.live("embedded client exposes plugin-backed web search", () =>
|
||||
),
|
||||
)
|
||||
|
||||
for (const continuation of [false, true]) {
|
||||
it.live(
|
||||
`session controls bypass a cold Location during interrupt cleanup (continue=${continuation})`,
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-session-controls-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const unavailable = yield* Ref.make(false)
|
||||
const boots = yield* Ref.make(0)
|
||||
const model = LanguageModel.make({ id: "session-controls", provider: "test", route: OpenAIChat.route })
|
||||
yield* llm.push(
|
||||
Stream.fromEffect(
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const models = Layer.effect(
|
||||
SessionRunnerModel.Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(boots, (count) => count + 1)
|
||||
if (yield* Ref.get(unavailable)) return yield* Effect.die("Location is unavailable during cleanup")
|
||||
return SessionRunnerModel.Service.of({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 100_000, output: 1_000 },
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const opencode = yield* fixture.sdk.OpenCode.create(
|
||||
{
|
||||
config: { directory: fixture.directory, project: false, content: "{}" },
|
||||
fs: { filewatcher: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
[llmClient, Layer.succeed(LLMClient.Service, llm.client)],
|
||||
[SessionRunnerModel.node, models],
|
||||
],
|
||||
},
|
||||
)
|
||||
// Release blocked cleanup before the embedded host's finalizer on assertion failure.
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(cleanupGate, undefined).pipe(Effect.asVoid))
|
||||
const session = yield* opencode.sessions.create({ title: "Session controls", location: location(fixture) })
|
||||
yield* opencode.sessions.wait({ sessionID: session.id })
|
||||
expect(yield* opencode.sessions.interrupt({ sessionID: session.id })).toEqual({ interrupted: false })
|
||||
expect(yield* Ref.get(boots)).toBe(0)
|
||||
|
||||
yield* opencode.sessions.prompt({ sessionID: session.id, text: "Start the model" })
|
||||
yield* Deferred.await(started).pipe(Effect.timeout("5 seconds"))
|
||||
expect(yield* Ref.get(boots)).toBe(1)
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
const steer = yield* opencode.sessions.prompt({ sessionID: session.id, text: "Continue here", resume: false })
|
||||
const queued = yield* opencode.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Keep this queued",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* Ref.set(unavailable, true)
|
||||
yield* opencode.debug.location.evict({ location: location(fixture) })
|
||||
expect(yield* opencode.debug.location.list()).toEqual([])
|
||||
const waiting = yield* opencode.sessions.wait({ sessionID: session.id }).pipe(Effect.forkScoped)
|
||||
expect(
|
||||
yield* opencode.sessions
|
||||
.interrupt({ sessionID: session.id, continue: continuation })
|
||||
.pipe(Effect.timeout("2 seconds")),
|
||||
).toEqual({ interrupted: true })
|
||||
yield* Deferred.await(cleanupStarted).pipe(Effect.timeout("2 seconds"))
|
||||
expect(waiting.pollUnsafe()).toBeUndefined()
|
||||
expect(yield* opencode.sessions.active()).toEqual({ [session.id]: { type: "running" } })
|
||||
expect(yield* opencode.sessions.interrupt({ sessionID: session.id })).toEqual({ interrupted: false })
|
||||
expect(yield* Ref.get(boots)).toBe(1)
|
||||
expect(yield* opencode.debug.location.list()).toEqual([])
|
||||
|
||||
// Only real continuation may acquire a fresh graph, after the interrupted drain settles.
|
||||
yield* Ref.set(unavailable, false)
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Fiber.join(waiting).pipe(Effect.timeout("5 seconds"))
|
||||
expect(yield* opencode.sessions.active()).toEqual({})
|
||||
expect(yield* Ref.get(boots)).toBe(continuation ? 2 : 1)
|
||||
expect(llm.requests).toHaveLength(continuation ? 2 : 1)
|
||||
expect((yield* opencode.sessions.inbox.list({ sessionID: session.id })).map((item) => item.id)).toEqual(
|
||||
continuation ? [queued.id] : [steer.id, queued.id],
|
||||
)
|
||||
}),
|
||||
).pipe(Effect.provide(TestLLM.layer({ fallback: TestLLM.text("Finished", "answer") }))),
|
||||
15_000,
|
||||
)
|
||||
}
|
||||
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import type { LocationServices } from "../location"
|
||||
import { requireSession } from "./session-validation"
|
||||
|
||||
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
SessionLocationMiddleware,
|
||||
@@ -16,6 +19,8 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
error: [InvalidRequestError, SessionNotFoundError],
|
||||
}) {}
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
|
||||
|
||||
export const sessionLocationLayer = Layer.effect(
|
||||
SessionLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
@@ -24,7 +29,27 @@ export const sessionLocationLayer = Layer.effect(
|
||||
|
||||
return SessionLocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* requireSession(db)
|
||||
const route = yield* HttpRouter.RouteContext
|
||||
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new InvalidRequestError({
|
||||
message: "Invalid session ID",
|
||||
field: "sessionID",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { SessionValidationMiddleware } from "@opencode-ai/protocol/middleware/session-validation"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
|
||||
|
||||
export const sessionValidationLayer = Layer.effect(
|
||||
SessionValidationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
return SessionValidationMiddleware.of((effect) => requireSession(database.db).pipe(Effect.andThen(effect)))
|
||||
}),
|
||||
)
|
||||
|
||||
// Middleware validates before query decoding, preserving the public session error precedence.
|
||||
export const requireSession = Effect.fn("HttpApi.requireSession")(function* (db: Database.Interface["db"]) {
|
||||
const route = yield* HttpRouter.RouteContext
|
||||
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
||||
Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new SessionNotFoundError({ sessionID, message: `Session not found: ${sessionID}` })
|
||||
return row
|
||||
})
|
||||
@@ -42,7 +42,6 @@ import { PtyEnvironment } from "./pty-environment"
|
||||
import { layer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
import { sessionValidationLayer } from "./middleware/session-validation"
|
||||
import { ServerInfo } from "./server-info"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
@@ -152,7 +151,6 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(sessionValidationLayer),
|
||||
Layer.provide(layer),
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const fixture = Effect.gen(function* () {
|
||||
const acquisitions: Location.Ref[] = []
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make((ref: Location.Ref) => {
|
||||
acquisitions.push(ref)
|
||||
return Layer.effectContext<LocationServices, LocationError, never>(Effect.die("Location must not be acquired"))
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
config: { project: false, content: "{}" },
|
||||
},
|
||||
{ overrides: [[LocationServiceMap.node, locations]] },
|
||||
)
|
||||
const post = (pathname: string, body?: unknown) =>
|
||||
Effect.promise(() =>
|
||||
handler(
|
||||
new Request(`http://opencode.local${pathname}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { acquisitions, post }
|
||||
})
|
||||
|
||||
it.live("session controls preserve malformed and unknown session errors without acquiring a Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture
|
||||
yield* Effect.forEach(
|
||||
["wait", "interrupt", "interrupt?continue=invalid", "prompt", "synthetic", "compact"],
|
||||
(operation) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(["invalid", "msg_invalid", "SES_invalid"], (id) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* server.post(`/api/session/${id}/${operation}`)
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "InvalidRequestError",
|
||||
message: "Invalid session ID",
|
||||
field: "sessionID",
|
||||
})
|
||||
}),
|
||||
)
|
||||
// Session IDs retain their existing loose prefix validation.
|
||||
yield* Effect.forEach(["ses", Session.ID.create()], (id) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* server.post(`/api/session/${id}/${operation}`)
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: id,
|
||||
message: `Session not found: ${id}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
expect(server.acquisitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("idle session controls do not acquire an unavailable Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture
|
||||
const id = Session.ID.create()
|
||||
expect((yield* server.post("/api/session", { id })).status).toBe(200)
|
||||
expect(server.acquisitions).toEqual([])
|
||||
|
||||
const waited = yield* server.post(`/api/session/${id}/wait`)
|
||||
expect(waited.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => waited.text())).toBe("")
|
||||
yield* Effect.forEach(["", "?continue=false", "?continue=true"], (query) =>
|
||||
Effect.gen(function* () {
|
||||
const interrupted = yield* server.post(`/api/session/${id}/interrupt${query}`)
|
||||
expect(interrupted.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => interrupted.json())).toEqual({ interrupted: false })
|
||||
}),
|
||||
)
|
||||
const invalidQuery = yield* server.post(`/api/session/${id}/interrupt?continue=invalid`)
|
||||
expect(invalidQuery.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => invalidQuery.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
kind: "Query",
|
||||
})
|
||||
expect(server.acquisitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("session admission endpoints still require the Location graph", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture
|
||||
const id = Session.ID.create()
|
||||
expect((yield* server.post("/api/session", { id })).status).toBe(200)
|
||||
yield* Effect.forEach(["prompt", "synthetic", "compact"], (operation) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* server.post(`/api/session/${id}/${operation}`, { text: "input", resume: false })
|
||||
expect(response.status).toBe(500)
|
||||
}),
|
||||
)
|
||||
expect(server.acquisitions.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SelectionBehavior } from "@opentui/core"
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
|
||||
type Toast = {
|
||||
@@ -16,7 +15,6 @@ type Renderer = {
|
||||
getSelectedText: () => string
|
||||
selectedRenderables: FocusableSelectionTarget[]
|
||||
isStart: boolean
|
||||
behavior: SelectionBehavior
|
||||
} | null
|
||||
clearSelection: () => void
|
||||
currentFocusedRenderable?: FocusableSelectionTarget | null
|
||||
@@ -36,16 +34,13 @@ export function copyOnSelectRelease(
|
||||
clipboard: ClipboardService,
|
||||
): boolean {
|
||||
if (!event.isDragging) return false
|
||||
const selection = renderer.getSelection()
|
||||
// Preserve the first click so OpenTUI can recognize the following double/triple click.
|
||||
if (selection?.isStart && selection.behavior === "cell") return false
|
||||
return copy(renderer, toast, clipboard)
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
if (selection.isStart && selection.behavior === "cell") {
|
||||
if (selection.isStart) {
|
||||
renderer.clearSelection()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ test("copy-on-select keeps a word highlight so a third click can select the line
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("")
|
||||
expect(writes).toEqual([])
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SelectionBehavior } from "@opentui/core"
|
||||
import type { ClipboardService } from "../../src/context/clipboard"
|
||||
import { Selection, copy, copyOnSelectRelease } from "../../src/util/selection"
|
||||
|
||||
@@ -9,13 +8,12 @@ function renderer() {
|
||||
getSelectedText: () => "beta",
|
||||
selectedRenderables: [],
|
||||
isStart: false,
|
||||
behavior: "cell" as const,
|
||||
}),
|
||||
clearSelection: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function setup(text: string, isStart: boolean, behavior: SelectionBehavior = "cell") {
|
||||
function setup(text: string, isStart: boolean) {
|
||||
const writes: string[] = []
|
||||
let clears = 0
|
||||
const clipboard: ClipboardService = {
|
||||
@@ -25,7 +23,7 @@ function setup(text: string, isStart: boolean, behavior: SelectionBehavior = "ce
|
||||
},
|
||||
}
|
||||
const renderer = {
|
||||
getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart, behavior }),
|
||||
getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart }),
|
||||
clearSelection: () => {
|
||||
clears++
|
||||
},
|
||||
@@ -43,7 +41,6 @@ test("copy writes selected text without clearing the highlight", () => {
|
||||
getSelectedText: () => "beta",
|
||||
selectedRenderables: [],
|
||||
isStart: false,
|
||||
behavior: "cell",
|
||||
}),
|
||||
clearSelection: () => {
|
||||
cleared = true
|
||||
@@ -78,20 +75,6 @@ test("copy-on-select ignores a later non-drag release", () => {
|
||||
expect(writes).toEqual(["beta"])
|
||||
})
|
||||
|
||||
test("copy-on-select preserves a click-only selection for subsequent clicks", () => {
|
||||
const value = setup("", true)
|
||||
expect(copyOnSelectRelease({ isDragging: true }, value.renderer, value.toast, value.clipboard)).toBeFalse()
|
||||
expect(value.clears()).toBe(0)
|
||||
expect(value.writes).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["word", "line"] as const)("copy-on-select copies a %s selection without pointer movement", (behavior) => {
|
||||
const value = setup("selected", true, behavior)
|
||||
expect(copyOnSelectRelease({ isDragging: true }, value.renderer, value.toast, value.clipboard)).toBeTrue()
|
||||
expect(value.clears()).toBe(0)
|
||||
expect(value.writes).toEqual(["selected"])
|
||||
})
|
||||
|
||||
test("clears a click-only selection without copying", () => {
|
||||
const value = setup("x", true)
|
||||
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse()
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
|
||||
Reference in New Issue
Block a user