Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e74453145d | |||
| 1b57c71eb9 | |||
| e263664963 | |||
| 47fdf60e76 | |||
| d5ca70fbc1 |
@@ -0,0 +1,36 @@
|
||||
export default {
|
||||
id: "Orchestrator",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("orchestrator", (agent) => {
|
||||
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
|
||||
agent.mode = "primary"
|
||||
agent.system = [
|
||||
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
|
||||
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
|
||||
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
|
||||
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
|
||||
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
|
||||
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
|
||||
"Synthesize minion results, decide next steps, and report back concisely.",
|
||||
].join("\n")
|
||||
})
|
||||
|
||||
agents.update("minion", (agent) => {
|
||||
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
|
||||
agent.mode = "subagent"
|
||||
agent.model = { providerID: "opencode", id: "glm-5.2" }
|
||||
agent.system = [
|
||||
"You are minion, a focused execution subagent for this repository.",
|
||||
"Complete the specific task delegated to you by Orchestrator using the available tools.",
|
||||
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
|
||||
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
|
||||
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
|
||||
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
|
||||
"Do not delegate to other subagents; execute the assigned work yourself.",
|
||||
].join("\n")
|
||||
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -104,25 +104,6 @@ bun dev api <operationId> --param key=value
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## Auditing installed `opencode2` sessions
|
||||
|
||||
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
|
||||
|
||||
For a supplied `ses_...` ID, compare three sources:
|
||||
|
||||
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
|
||||
- The database's ordered `event` rows for durable history.
|
||||
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
|
||||
|
||||
Locate an uncertain database without modifying it:
|
||||
|
||||
```bash
|
||||
SESSION=ses_...
|
||||
for db in ~/.local/share/opencode/*.db; do
|
||||
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
|
||||
done
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
|
||||
|
||||
@@ -151,7 +151,6 @@ const table = sqliteTable("session", {
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
|
||||
@@ -928,7 +928,6 @@ export type SessionContextOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1007,23 +1006,20 @@ export type SessionContextOutput = {
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
readonly executed?: boolean
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
@@ -1061,7 +1057,37 @@ export type SessionContextOutput = {
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| {
|
||||
readonly type: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1073,7 +1099,7 @@ export type SessionContextOutput = {
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: string
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -1081,7 +1107,56 @@ export type SessionContextOutput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error?:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -1212,6 +1287,71 @@ export type SessionLogOutput =
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.succeeded"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.interrupted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
@@ -1321,7 +1461,7 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly finish: string
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
@@ -1343,7 +1483,33 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1353,7 +1519,7 @@ export type SessionLogOutput =
|
||||
readonly type: "session.text.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1362,12 +1528,7 @@ export type SessionLogOutput =
|
||||
readonly type: "session.text.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly text: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1379,8 +1540,7 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1393,9 +1553,8 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1437,12 +1596,9 @@ export type SessionLogOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly tool: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1481,10 +1637,8 @@ export type SessionLogOutput =
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly resultState?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1498,32 +1652,77 @@ export type SessionLogOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly resultState?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.retried"
|
||||
readonly type: "session.retry.scheduled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly attempt: number
|
||||
readonly error: {
|
||||
readonly message: string
|
||||
readonly statusCode?: number
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string }
|
||||
readonly responseBody?: string
|
||||
readonly metadata?: { readonly [x: string]: string }
|
||||
}
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1589,7 +1788,7 @@ export type SessionLogOutput =
|
||||
readonly type: "session.revert.committed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
||||
readonly data: { readonly sessionID: string; readonly to: string }
|
||||
}
|
||||
)
|
||||
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
|
||||
@@ -1622,7 +1821,6 @@ export type SessionMessageOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1701,23 +1899,20 @@ export type SessionMessageOutput = {
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
readonly executed?: boolean
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
@@ -1755,7 +1950,37 @@ export type SessionMessageOutput = {
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| {
|
||||
readonly type: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1767,7 +1992,7 @@ export type SessionMessageOutput = {
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: string
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -1775,7 +2000,56 @@ export type SessionMessageOutput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error?:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -1822,7 +2096,6 @@ export type MessageListOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -1901,23 +2174,20 @@ export type MessageListOutput = {
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly time?: { readonly created: number; readonly completed?: number }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
readonly executed?: boolean
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly providerResultState?: { readonly [x: string]: JsonValue }
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
@@ -1955,7 +2225,37 @@ export type MessageListOutput = {
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| {
|
||||
readonly type: "provider.rate-limit"
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number
|
||||
}
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| {
|
||||
readonly type: "aborted"
|
||||
readonly message: string
|
||||
readonly reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
@@ -1967,7 +2267,7 @@ export type MessageListOutput = {
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: string
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -1975,7 +2275,56 @@ export type MessageListOutput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error?:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -4488,14 +4837,63 @@ export type EventSubscribeOutput =
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.settled"
|
||||
readonly type: "session.execution.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.succeeded"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.failed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.execution.interrupted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
@@ -4605,7 +5003,7 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly finish: string
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
@@ -4627,7 +5025,29 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4637,7 +5057,7 @@ export type EventSubscribeOutput =
|
||||
readonly type: "session.text.started"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4645,12 +5065,7 @@ export type EventSubscribeOutput =
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly delta: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4659,12 +5074,7 @@ export type EventSubscribeOutput =
|
||||
readonly type: "session.text.ended"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly text: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly text: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4676,8 +5086,7 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4686,12 +5095,7 @@ export type EventSubscribeOutput =
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly delta: string
|
||||
}
|
||||
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly delta: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -4703,9 +5107,8 @@ export type EventSubscribeOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4760,12 +5163,9 @@ export type EventSubscribeOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly tool: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly state?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4804,10 +5204,8 @@ export type EventSubscribeOutput =
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly resultState?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4821,32 +5219,69 @@ export type EventSubscribeOutput =
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
readonly executed: boolean
|
||||
readonly resultState?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.retried"
|
||||
readonly type: "session.retry.scheduled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly attempt: number
|
||||
readonly error: {
|
||||
readonly message: string
|
||||
readonly statusCode?: number
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string }
|
||||
readonly responseBody?: string
|
||||
readonly metadata?: { readonly [x: string]: string }
|
||||
}
|
||||
readonly at: number
|
||||
readonly error:
|
||||
| { readonly type: "provider.rate-limit"; readonly message: string; readonly retryAfterMs?: number }
|
||||
| { readonly type: "provider.auth"; readonly message: string }
|
||||
| { readonly type: "provider.quota"; readonly message: string }
|
||||
| { readonly type: "provider.content-filter"; readonly message: string }
|
||||
| { readonly type: "provider.transport"; readonly message: string }
|
||||
| { readonly type: "provider.internal"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-output"; readonly message: string }
|
||||
| { readonly type: "provider.invalid-request"; readonly message: string }
|
||||
| { readonly type: "provider.no-route"; readonly message: string }
|
||||
| { readonly type: "provider.unknown"; readonly message: string }
|
||||
| {
|
||||
readonly type: "permission.rejected"
|
||||
readonly message: string
|
||||
readonly permission: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
}
|
||||
| { readonly type: "tool.unknown"; readonly message: string; readonly name: string }
|
||||
| { readonly type: "tool.stale"; readonly message: string; readonly name?: string }
|
||||
| { readonly type: "tool.execution"; readonly message: string }
|
||||
| { readonly type: "tool.result-missing"; readonly message: string; readonly callID?: string }
|
||||
| { readonly type: "aborted"; readonly message: string; readonly reason?: "user" | "shutdown" | "timeout" }
|
||||
| { readonly type: "unknown"; readonly message: string; readonly agent?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -4920,7 +5355,7 @@ export type EventSubscribeOutput =
|
||||
readonly type: "session.revert.committed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly messageID: string }
|
||||
readonly data: { readonly sessionID: string; readonly to: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -2,7 +2,8 @@ export * as ConfigExternalPlugin from "./external"
|
||||
|
||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config"
|
||||
@@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({
|
||||
module: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
let importGeneration = 0
|
||||
const moduleCache = createRequire(import.meta.url).cache
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-plugin",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -42,6 +46,7 @@ export const Plugin = define({
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const active = new Set<string>()
|
||||
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
|
||||
const configured: { package: string; options?: Record<string, unknown> }[] = []
|
||||
|
||||
@@ -105,7 +110,7 @@ export const Plugin = define({
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
@@ -113,13 +118,40 @@ export const Plugin = define({
|
||||
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
||||
plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
}
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
})
|
||||
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
|
||||
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
|
||||
const plugins = yield* load()
|
||||
const next = new Set(plugins.map((plugin) => plugin.id))
|
||||
for (const id of active) {
|
||||
if (!next.has(id)) yield* ctx.plugin.remove(id)
|
||||
}
|
||||
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
|
||||
active.clear()
|
||||
for (const id of next) active.add(id)
|
||||
})
|
||||
|
||||
yield* reconcile()
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reconcile()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function cacheBust(entrypoint: string) {
|
||||
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
|
||||
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
|
||||
url.searchParams.set("opencode-reload", String(++importGeneration))
|
||||
return url.href
|
||||
}
|
||||
|
||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||
|
||||
+2
@@ -44,5 +44,7 @@ export const migrations = (
|
||||
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
|
||||
import("./migration/20260703181610_event_created_column"),
|
||||
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
|
||||
import("./migration/20260703200000_reset_v2_event_fragments"),
|
||||
import("./migration/20260703210000_reset_v2_execution_errors"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703200000_reset_v2_event_fragments",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260703210000_reset_v2_execution_errors",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -45,7 +45,7 @@ const FILES = [
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
|
||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||
|
||||
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
|
||||
for (const pattern of opts?.whitelist || []) {
|
||||
|
||||
@@ -59,7 +59,14 @@ export type AskResult = typeof AskResult.Type
|
||||
|
||||
export const Event = Permission.Event
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Permission rejected: ${this.permission}`
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
|
||||
feedback: Schema.String,
|
||||
@@ -67,7 +74,13 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
|
||||
rules: Permission.Ruleset,
|
||||
}) {}
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Permission denied: ${this.permission}`
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
|
||||
requestID: ID,
|
||||
@@ -119,9 +132,17 @@ const layer = Layer.effect(
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.forEach(
|
||||
pending.values(),
|
||||
(item) =>
|
||||
Deferred.fail(
|
||||
item.deferred,
|
||||
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
|
||||
),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
@@ -201,6 +222,8 @@ const layer = Layer.effect(
|
||||
if (result.effect === "deny") {
|
||||
return yield* new DeniedError({
|
||||
rules: relevant(input, result.rules),
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
@@ -230,7 +253,12 @@ const layer = Layer.effect(
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
input.message
|
||||
? new CorrectedError({ feedback: input.message })
|
||||
: new RejectedError({
|
||||
permission: existing.request.action,
|
||||
resources: [...existing.request.resources],
|
||||
}),
|
||||
)
|
||||
pending.delete(input.requestID)
|
||||
for (const [id, item] of pending) {
|
||||
@@ -240,7 +268,10 @@ const layer = Layer.effect(
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
yield* Deferred.fail(
|
||||
item.deferred,
|
||||
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
|
||||
)
|
||||
pending.delete(id)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -302,7 +302,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
}),
|
||||
},
|
||||
tool: {
|
||||
register: (input, options) => tools.register(input, options),
|
||||
register: (input) => tools.register(input),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
|
||||
@@ -540,8 +540,7 @@ const layer = Layer.effect(
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
return yield* shell.create({ command: input.command, cwd: session.location.directory })
|
||||
})
|
||||
.pipe(Effect.provide(locations.get(session.location)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* events.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
@@ -564,7 +563,8 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
})
|
||||
.pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: completed.shell,
|
||||
|
||||
@@ -225,7 +225,13 @@ const make = (dependencies: Dependencies) => {
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
||||
}
|
||||
}
|
||||
|
||||
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
||||
error: SessionError.Error,
|
||||
}) {
|
||||
override get message() {
|
||||
return this.error.message
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
|
||||
"Session.UserInterruptedError",
|
||||
{},
|
||||
) {
|
||||
override get message() {
|
||||
return "Session interrupted by user"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, DateTime, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { LocationServiceMap } from "../../location-service-map"
|
||||
import { makeGlobalNode } from "../../effect/app-node"
|
||||
@@ -8,6 +8,16 @@ import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { UserInterruptedError } from "../error"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
|
||||
const failure = Cause.squash(exit.cause)
|
||||
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
|
||||
return { type: "failed" as const, error: toSessionError(failure) }
|
||||
}
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
const layer = Layer.effect(
|
||||
@@ -16,7 +26,23 @@ const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const events = yield* EventV2.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
|
||||
Effect.annotateLogs({ sessionID }),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
const coordinator = yield* SessionRunCoordinator.make<
|
||||
SessionSchema.ID,
|
||||
SessionRunner.RunError,
|
||||
"user" | "shutdown" | "superseded"
|
||||
>({
|
||||
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
@@ -29,28 +55,31 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
}),
|
||||
// One ExecutionSettled per execution (busy period), covering every coalesced drain.
|
||||
settled: (sessionID, exit) =>
|
||||
Effect.gen(function* () {
|
||||
const failure =
|
||||
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
|
||||
yield* events.publish(SessionEvent.ExecutionSettled, {
|
||||
sessionID,
|
||||
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
|
||||
error:
|
||||
failure !== undefined
|
||||
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
|
||||
: undefined,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.asVoid,
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const outcome = terminal(exit, reason)
|
||||
if (outcome.type === "succeeded") {
|
||||
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
|
||||
return
|
||||
}
|
||||
yield* events.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID,
|
||||
error: outcome.error,
|
||||
})
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: coordinator.interrupt,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { castDraft, produce, type WritableDraft } from "immer"
|
||||
import { Effect } from "effect"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
@@ -8,7 +8,6 @@ export type MemoryState = {
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
@@ -30,15 +29,6 @@ export function memory(state: MemoryState): Adapter {
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
|
||||
return {
|
||||
getModel() {
|
||||
return Effect.sync(
|
||||
() =>
|
||||
state.messages.findLast(
|
||||
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
|
||||
message.type === "model-switched" || message.type === "assistant",
|
||||
)?.model,
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.sync(() => {
|
||||
const index = latestAssistantIndex()
|
||||
@@ -99,11 +89,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -111,6 +101,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||
})
|
||||
|
||||
const clearCurrentRetry = Effect.gen(function* () {
|
||||
const assistant = yield* adapter.getCurrentAssistant()
|
||||
if (assistant?.retry) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(assistant, (draft) => {
|
||||
draft.retry = undefined
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* SessionEvent.All.match(event, {
|
||||
"session.agent.selected": (event) => {
|
||||
@@ -125,26 +126,25 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getModel()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.moved": () => Effect.void,
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
"session.prompt.promoted": () => Effect.void,
|
||||
"session.prompt.admitted": () => Effect.void,
|
||||
"session.execution.settled": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
@@ -205,10 +205,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.step.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
|
||||
if (existing) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(existing, (draft) => {
|
||||
draft.agent = event.data.agent
|
||||
draft.model = castDraft(event.data.model)
|
||||
draft.retry = undefined
|
||||
draft.error = undefined
|
||||
draft.finish = undefined
|
||||
draft.time.completed = undefined
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.retry = undefined
|
||||
draft.time.completed = event.created
|
||||
}),
|
||||
)
|
||||
@@ -244,25 +260,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.created
|
||||
draft.finish = "error"
|
||||
draft.error = event.data.error
|
||||
draft.error = castDraft(event.data.error)
|
||||
draft.retry = undefined
|
||||
})
|
||||
},
|
||||
"session.text.started": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
|
||||
)
|
||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||
})
|
||||
},
|
||||
"session.text.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
const match = latestText(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.text.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
const match = latestText(draft)
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
},
|
||||
@@ -292,7 +307,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
match.provider = event.data.provider
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.time.ran = event.created
|
||||
match.state = castDraft(
|
||||
SessionMessage.ToolStateRunning.make({
|
||||
@@ -318,11 +334,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
match.state = castDraft(
|
||||
SessionMessage.ToolStateCompleted.make({
|
||||
@@ -341,11 +354,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
match.state = castDraft(
|
||||
SessionMessage.ToolStateError.make({
|
||||
@@ -366,9 +376,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
castDraft(
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
@@ -377,21 +386,29 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
},
|
||||
"session.reasoning.delta": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
const match = latestReasoning(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.reasoning.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
const match = latestReasoning(draft)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.retry.scheduled": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: DateTime.makeUnsafe(event.data.at),
|
||||
error: castDraft(event.data.error),
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.retried": () => Effect.void,
|
||||
"session.compaction.started": () => Effect.void,
|
||||
"session.compaction.delta": () => Effect.void,
|
||||
"session.compaction.ended": (event) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ModelV2 } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionV1 } from "../v1/session"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
@@ -357,17 +356,6 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)),
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.gen(function* () {
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
@@ -582,13 +570,13 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Renamed, (event) =>
|
||||
@@ -634,6 +622,9 @@ const layer = Layer.effectDiscard(
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||
@@ -660,7 +651,7 @@ const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||
db
|
||||
@@ -687,14 +678,11 @@ const layer = Layer.effectDiscard(
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`))
|
||||
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
|
||||
yield* db
|
||||
.delete(SessionMessageTable)
|
||||
.where(
|
||||
|
||||
@@ -113,6 +113,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID: session.id,
|
||||
messageID: session.revert.messageID,
|
||||
to: session.revert.messageID,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunCoordinator from "./run-coordinator"
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E> {
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Snapshots keys with an execution owned by this coordinator. */
|
||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
@@ -11,7 +11,7 @@ export interface Coordinator<Key, E> {
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -23,11 +23,13 @@ export interface Coordinator<Key, E> {
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E> = {
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
settling: boolean
|
||||
interruptionReason?: Reason
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,19 +43,21 @@ type Execution<E> = {
|
||||
* waiters get this exit
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E>(options: {
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
* Runs in the execution fiber for every exit, including interruption, after the final
|
||||
* drain and before the execution settles (waiters resolve after it completes).
|
||||
*/
|
||||
readonly settled?: (key: Key, exit: Exit.Exit<void, E>) => Effect.Effect<void>
|
||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
|
||||
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const executions = new Map<Key, Execution<E>>()
|
||||
const executions = new Map<Key, Execution<E, Reason>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
@@ -66,15 +70,25 @@ export const make = <Key, E>(options: {
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
settling: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
||||
// failing self-waking executions from growing the stack across successor starts.
|
||||
// Drains start one tick after wake; callers observe progress through events or run.
|
||||
execution.owner = fork(
|
||||
Effect.yieldNow.pipe(
|
||||
Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)),
|
||||
Effect.andThen(loop(key, execution, force)),
|
||||
Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void),
|
||||
Effect.onExit((exit) =>
|
||||
Effect.sync(() => {
|
||||
execution.settling = true
|
||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||
),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
@@ -85,7 +99,7 @@ export const make = <Key, E>(options: {
|
||||
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
@@ -112,12 +126,13 @@ export const make = <Key, E>(options: {
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined) return Effect.void
|
||||
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| StepFailedError
|
||||
| UserInterruptedError
|
||||
| SystemContext.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -31,6 +32,7 @@ import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
import { SessionInput } from "../input"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
@@ -43,6 +45,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
import { StepFailedError, UserInterruptedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
@@ -53,10 +58,10 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - Session ownership and controls
|
||||
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
||||
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
|
||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||
* - [x] Honor optional agent step limits.
|
||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
||||
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
|
||||
*
|
||||
* - Runtime context assembly
|
||||
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
||||
@@ -65,7 +70,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - [x] Translate every projected V2 Session message variant into canonical
|
||||
* `@opencode-ai/llm` messages.
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
|
||||
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
@@ -86,7 +91,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||
*
|
||||
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
|
||||
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
|
||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
@@ -136,17 +141,14 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
callID: tool.id,
|
||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||
provider: {
|
||||
executed: tool.provider?.executed === true,
|
||||
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
|
||||
},
|
||||
error: { type: "tool.stale", message: "Tool execution interrupted", name: tool.name },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error | UserInterruptedError>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
@@ -171,6 +173,7 @@ const layer = Layer.effect(
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
@@ -184,7 +187,7 @@ const layer = Layer.effect(
|
||||
loadSystemContext(agent, session.id),
|
||||
session.id,
|
||||
)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
@@ -228,21 +231,23 @@ const layer = Layer.effect(
|
||||
// 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,
|
||||
provider: model.provider,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
serialized(publisher.publish(event, outputPaths))
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
||||
serialized(publisher.publish(event, outputPaths, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
@@ -250,7 +255,12 @@ const layer = Layer.effect(
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: "Tools are disabled after the maximum agent steps",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
@@ -273,6 +283,15 @@ const layer = Layer.effect(
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.outputPaths ?? [],
|
||||
settlement.error,
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
settlement.error?.type === "permission.rejected"
|
||||
? serialized(publisher.failAssistant(settlement.error)).pipe(
|
||||
Effect.andThen(Effect.fail(new UserInterruptedError())),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -319,7 +338,7 @@ const layer = Layer.effect(
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||
)
|
||||
@@ -331,8 +350,26 @@ const layer = Layer.effect(
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
(agent.info?.steps === undefined || currentStep < agent.info.steps)
|
||||
) {
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
error,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
@@ -342,27 +379,30 @@ const layer = Layer.effect(
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
|
||||
const settledError =
|
||||
settled._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(settled.cause)) : undefined
|
||||
const permissionRejected = settledError instanceof UserInterruptedError
|
||||
|
||||
if (questionDismissed || streamInterrupted || toolsInterrupted) {
|
||||
if (questionDismissed || permissionRejected || streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* serialized(publisher.failAssistant("Step interrupted"))
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
// Match V1: dismissing a question halts the loop like an interruption.
|
||||
if (questionDismissed) return yield* Effect.interrupt
|
||||
if (questionDismissed || permissionRejected) return yield* new UserInterruptedError()
|
||||
}
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the step still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
||||
const settledFailure =
|
||||
settled._tag === "Failure" && !toolsInterrupted && !permissionRejected ? settled.cause : undefined
|
||||
const infraError =
|
||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||
if (settledFailure !== undefined) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
if (infraError !== undefined)
|
||||
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
|
||||
const error = toSessionError(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
@@ -371,13 +411,21 @@ const layer = Layer.effect(
|
||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
||||
// A provider error orphans recorded local calls; a clean stream can still leave
|
||||
// hosted calls without results.
|
||||
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
if (stream._tag === "Success" && !providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
const stepFailure = publisher.stepFailure()
|
||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation: !providerFailed && needsContinuation,
|
||||
@@ -398,8 +446,31 @@ const layer = Layer.effect(
|
||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
while (true) {
|
||||
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
const attempt = yield* Effect.suspend(() =>
|
||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||
).pipe(
|
||||
Effect.tapError((error) =>
|
||||
error instanceof SessionRunnerRetry.RetryableFailure
|
||||
? Effect.sync(() => {
|
||||
currentStep = error.step + 1
|
||||
assistantMessageID = error.assistantMessageID
|
||||
currentPromotion = undefined
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
|
||||
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
|
||||
return events
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: error.assistantMessageID,
|
||||
error: error.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
@@ -408,8 +479,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
|
||||
// drain here.
|
||||
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
readonly provider: string
|
||||
readonly snapshot?: string
|
||||
readonly assistantMessageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
@@ -41,10 +44,10 @@ const message = (value: unknown) => {
|
||||
|
||||
type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
||||
| { readonly error: SessionError.Error }
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
@@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
called: boolean
|
||||
settled: boolean
|
||||
providerExecuted: boolean
|
||||
providerMetadata?: ProviderMetadata
|
||||
}
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let assistantActive = false
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let assistantFailed = false
|
||||
let providerFailed = false
|
||||
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
|
||||
let retryEvidence = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
assistantActive = true
|
||||
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
@@ -86,15 +94,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
||||
|
||||
const fragments = (
|
||||
name: string,
|
||||
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
|
||||
ended: (id: string, value: string, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
single = false,
|
||||
) => {
|
||||
const chunks = new Map<string, string[]>()
|
||||
const start = (id: string) =>
|
||||
Effect.suspend(() => {
|
||||
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
||||
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
|
||||
chunks.set(id, [])
|
||||
return Effect.void
|
||||
})
|
||||
@@ -105,10 +116,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
current.push(value)
|
||||
return Effect.void
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* ended(id, current.join(""), providerMetadata)
|
||||
yield* ended(id, current.join(""), state)
|
||||
chunks.delete(id)
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
@@ -117,26 +128,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return { start, append, end, flush }
|
||||
}
|
||||
|
||||
const text = fragments("text", (textID, value) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
textID,
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
const text = fragments(
|
||||
"text",
|
||||
(_textID, value) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
reasoningID,
|
||||
text: value,
|
||||
providerMetadata,
|
||||
})
|
||||
}),
|
||||
const reasoning = fragments(
|
||||
"reasoning",
|
||||
(_reasoningID, value, state) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
text: value,
|
||||
state,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const toolInput = fragments("tool input", (callID, value) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -191,21 +206,21 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error) {
|
||||
if (assistantFailed) return
|
||||
yield* flush()
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
assistantActive = false
|
||||
assistantFailed = true
|
||||
stepFailure = error
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: { type: "unknown", message },
|
||||
error,
|
||||
})
|
||||
})
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
message: string,
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
for (const [callID, tool] of tools) {
|
||||
@@ -215,11 +230,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error: { type: "unknown", message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
|
||||
},
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -232,16 +244,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
error?: SessionError.Error,
|
||||
) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
return
|
||||
case "text-start":
|
||||
retryEvidence = true
|
||||
yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
textID: event.id,
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
@@ -249,7 +263,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
textID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
@@ -257,12 +270,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* text.end(event.id)
|
||||
return
|
||||
case "reasoning-start":
|
||||
retryEvidence = true
|
||||
yield* reasoning.start(event.id)
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
reasoningID: event.id,
|
||||
providerMetadata: event.providerMetadata,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
@@ -270,14 +283,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
reasoningID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, event.providerMetadata)
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "tool-input-start":
|
||||
retryEvidence = true
|
||||
yield* startToolInput(event)
|
||||
return
|
||||
case "tool-input-delta": {
|
||||
@@ -299,6 +312,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
retryEvidence = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)!
|
||||
if (!tool.inputEnded) yield* endToolInput(event)
|
||||
@@ -307,21 +321,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||
tool.called = true
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
tool.providerMetadata = event.providerMetadata
|
||||
const state = providerState(event.providerMetadata)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
tool: event.name,
|
||||
input: record(event.input),
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
executed: tool.providerExecuted,
|
||||
state,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-result": {
|
||||
retryEvidence = true
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
@@ -331,11 +343,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||
}
|
||||
tool.settled = true
|
||||
const result = settledOutput(event.output, event.result)
|
||||
const provider = {
|
||||
executed: event.providerExecuted === true || tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
}
|
||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if ("error" in result) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -343,7 +353,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
callID: event.id,
|
||||
error: result.error,
|
||||
result: event.result,
|
||||
provider,
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -353,12 +364,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
callID: event.id,
|
||||
...result,
|
||||
outputPaths,
|
||||
...(provider.executed ? { result: event.result } : {}),
|
||||
provider,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-error": {
|
||||
retryEvidence = true
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
@@ -369,25 +382,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: { type: "unknown", message: event.message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
error:
|
||||
event.message === `Unknown tool: ${event.name}`
|
||||
? { type: "tool.unknown", message: event.message, name: event.name }
|
||||
: { type: "tool.execution", message: event.message },
|
||||
executed: tool.providerExecuted,
|
||||
resultState: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
assistantActive = false
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
if (event.reason === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
return
|
||||
}
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant(event.message)
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message })
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -397,9 +415,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
flush,
|
||||
failAssistant,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { SessionRunner } from "./index"
|
||||
|
||||
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||
readonly cause: LLMError
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: SessionError.Error
|
||||
readonly step: number
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||
Schedule.exponential("2 seconds").pipe(
|
||||
Schedule.take(4),
|
||||
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
Schedule.modifyDelay((failure, delay) => {
|
||||
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
metadata.input instanceof RetryableFailure
|
||||
? events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
@@ -21,6 +21,11 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
provider: string,
|
||||
state: Record<string, unknown> | undefined,
|
||||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
tool.state.status === "pending"
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
@@ -31,7 +36,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input: toolInput(tool),
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
|
||||
@@ -40,14 +45,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
||||
const result =
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result,
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
@@ -56,11 +61,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result:
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||
resultType: "error",
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
@@ -78,17 +83,22 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(model.provider, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
||||
if (item.provider?.executed !== true) return [call]
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseProviderMetadata ? providerMetadata(model.provider, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
@@ -98,9 +108,14 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
||||
})
|
||||
const results = message.content
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
|
||||
.map((item) =>
|
||||
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
||||
toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.filter((message) => message !== undefined)
|
||||
.map(Message.tool)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Integration } from "../integration"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { StepFailedError, UserInterruptedError } from "./error"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return {
|
||||
type: "provider.rate-limit",
|
||||
message: cause.reason.message,
|
||||
retryAfterMs: cause.reason.retryAfterMs,
|
||||
}
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cause instanceof PermissionV2.DeniedError || cause instanceof PermissionV2.RejectedError)
|
||||
return {
|
||||
type: "permission.rejected",
|
||||
message: cause.message,
|
||||
permission: cause.permission,
|
||||
resources: [...cause.resources],
|
||||
}
|
||||
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message, reason: "user" }
|
||||
if (cause instanceof ToolFailure)
|
||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message, reason: "user" }
|
||||
if (
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedApiError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
@@ -28,8 +28,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
||||
@@ -73,12 +73,12 @@ export const Plugin = {
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string) => {
|
||||
const fail = (path: string, error?: unknown) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix })
|
||||
return new ToolFailure({ message: prefix, error })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -150,7 +150,7 @@ export const Plugin = {
|
||||
before,
|
||||
after: update.content,
|
||||
})
|
||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
||||
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
@@ -180,11 +180,11 @@ export const Plugin = {
|
||||
content: change.content,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
}).pipe(Effect.mapError((error) => fail(change.path, error))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
|
||||
@@ -111,8 +111,9 @@ export const Plugin = {
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
error,
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
@@ -37,7 +36,6 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
export const Plugin = {
|
||||
id: "core-glob-tool",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
@@ -73,13 +71,6 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const cwd = path.resolve(location.directory, input.path ?? ".")
|
||||
yield* fs
|
||||
.stat(cwd)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
return yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
@@ -97,10 +88,8 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -91,13 +91,7 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const target = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs
|
||||
.stat(target)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return yield* ripgrep
|
||||
.grep({
|
||||
cwd: info?.type === "Directory" ? target : path.dirname(target),
|
||||
@@ -128,11 +122,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
||||
),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as McpTool from "./mcp"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
@@ -10,11 +11,35 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
const MAX_NAME_LENGTH = 64
|
||||
const HASH_LENGTH = 8
|
||||
|
||||
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
|
||||
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
|
||||
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
|
||||
|
||||
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
|
||||
|
||||
/**
|
||||
* Registry and permission action name for an MCP tool.
|
||||
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
|
||||
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
|
||||
* and hashed down when it would exceed the 64-char limit.
|
||||
*/
|
||||
export const name = (server: string, tool: string) =>
|
||||
`${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
export const name = (server: string, tool: string) => {
|
||||
const joined = sanitize(server) + "_" + sanitize(tool)
|
||||
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
|
||||
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
|
||||
}
|
||||
|
||||
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
|
||||
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
|
||||
|
||||
const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) =>
|
||||
content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim()
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -25,71 +50,47 @@ export const layer = Layer.effectDiscard(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let current: Scope.Closeable | undefined
|
||||
|
||||
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
||||
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
|
||||
}),
|
||||
})
|
||||
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = new Map<string, Record<string, Tool.AnyTool>>()
|
||||
const used = new Set<string>()
|
||||
const record: Record<string, Tool.AnyTool> = {}
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const group = groups.get(tool.server) ?? {}
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group[tool.name] = Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
return {
|
||||
structured: result.structured ?? {},
|
||||
content: result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
groups.set(tool.server, group)
|
||||
const initial = name(tool.server, tool.name)
|
||||
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
|
||||
used.add(key)
|
||||
record[key] = make(tool.server, tool)
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), {
|
||||
discard: true,
|
||||
}).pipe(Scope.provide(next), Effect.orDie)
|
||||
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* events
|
||||
.subscribe(McpEvent.ToolsChanged)
|
||||
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
|
||||
Effect.andThen(
|
||||
question
|
||||
.ask({
|
||||
|
||||
@@ -106,7 +106,9 @@ export const Plugin = {
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root)
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
@@ -130,7 +132,7 @@ export const Plugin = {
|
||||
error instanceof Image.SizeError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message })
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ import { definition, permission, registrationEntries, settle, type AnyTool, type
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -23,10 +25,7 @@ export type ExecuteInput = {
|
||||
export interface Interface {
|
||||
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface MaterializeInput {
|
||||
@@ -43,6 +42,7 @@ export interface Settlement {
|
||||
readonly result: ToolResultValue
|
||||
readonly output?: ToolOutput
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly error?: SessionError.Error
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
@@ -52,13 +52,7 @@ const registryLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = {
|
||||
readonly identity: object
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly deferred: boolean
|
||||
}
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
@@ -69,9 +63,15 @@ const registryLayer = Layer.effect(
|
||||
type: "error" as const,
|
||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
||||
},
|
||||
error: advertised
|
||||
? ({ type: "tool.stale", message: `Stale tool call: ${input.call.name}`, name: input.call.name } as const)
|
||||
: ({ type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: input.call.name } as const),
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
return {
|
||||
result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` },
|
||||
error: { type: "tool.stale" as const, message: `Stale tool call: ${input.call.name}`, name: input.call.name },
|
||||
}
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
@@ -94,7 +94,10 @@ const registryLayer = Layer.effect(
|
||||
).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
Effect.succeed({
|
||||
result: { type: "error" as const, value: failure.message },
|
||||
error: toSessionError(failure),
|
||||
}),
|
||||
),
|
||||
)
|
||||
let settlement: Settlement
|
||||
@@ -132,37 +135,25 @@ const registryLayer = Layer.effect(
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
||||
const entries = registrationEntries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration: {
|
||||
identity: {},
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
deferred: options?.deferred ?? false,
|
||||
},
|
||||
},
|
||||
])
|
||||
for (const [name, tool] of entries)
|
||||
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const registrations =
|
||||
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
for (const [name] of entries) {
|
||||
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(name, registrations)
|
||||
else local.delete(name)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -179,11 +170,7 @@ const registryLayer = Layer.effect(
|
||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||
for (const [name, registration] of registrations) {
|
||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||
if (
|
||||
registration.deferred ||
|
||||
wrongEditTool ||
|
||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
||||
)
|
||||
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
|
||||
registrations.delete(name)
|
||||
}
|
||||
return {
|
||||
@@ -191,7 +178,10 @@ const registryLayer = Layer.effect(
|
||||
settle: (input) => {
|
||||
const registration = registrations.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
||||
return Effect.succeed({
|
||||
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: input.call.name },
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -18,9 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
const BACKGROUND_STARTED = "The command has not completed; it is now running in the background."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
@@ -55,11 +53,10 @@ const Output = Schema.Struct({
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
if (output.status === "running") return undefined
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.status === "running")
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
@@ -247,9 +244,9 @@ export const Plugin = {
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const result = yield* runtime.job
|
||||
.block({ id: job.id, sessionID: context.sessionID })
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
@@ -260,14 +257,19 @@ export const Plugin = {
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return {
|
||||
...(yield* settleShell()),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -104,7 +104,9 @@ export const Plugin = {
|
||||
const parent = yield* runtime.session
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
const agent = yield* agents.resolve(input.agent)
|
||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
@@ -123,7 +125,9 @@ export const Plugin = {
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
|
||||
const background = input.background === true
|
||||
|
||||
@@ -46,7 +46,7 @@ export const Plugin = {
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -3,12 +3,9 @@ export * as Tools from "./tools"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export type RegisterOptions = Tool.RegisterOptions
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
options?: Tool.RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export const Plugin = {
|
||||
format: input.format,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -243,7 +243,11 @@ export const Plugin = {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
@@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads changed plugin source from the same entrypoint", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const plugin = path.join(tmp.path, "plugin.ts")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ plugins: [plugin] }),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fsUtil),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(Config.Service, config),
|
||||
)
|
||||
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
|
||||
@@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
|
||||
}
|
||||
return yield* Effect.die(`Timed out waiting for agent ${id}`)
|
||||
})
|
||||
|
||||
const waitForAgentDescription = Effect.fnUntraced(function* (
|
||||
agents: AgentV2.Interface,
|
||||
id: string,
|
||||
description: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function pluginSource(description: string) {
|
||||
return `export default {
|
||||
id: "source-hot-reload",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("hot-reload", (agent) => {
|
||||
agent.description = ${JSON.stringify(description)}
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
}`
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.live("reloads config-backed domains without reloading external plugins", () =>
|
||||
it.live("reloads every config-backed domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -69,7 +69,8 @@ describe("config plugin reloads", () => {
|
||||
(yield* commands.get("second"))?.description === "Second command" &&
|
||||
(yield* references.list()).some((reference) => reference.name === "second") &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined &&
|
||||
(yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -80,7 +81,10 @@ describe("config plugin reloads", () => {
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
|
||||
entries = [config("second")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
|
||||
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
|
||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetExecutionErrorsMigration from "@opencode-ai/core/database/migration/20260703210000_reset_v2_execution_errors"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -38,6 +39,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("resets incompatible V2 execution and error history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
|
||||
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
|
||||
yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [resetExecutionErrorsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/index.js")).toBe(true)
|
||||
@@ -10,30 +8,3 @@ test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/bar")).toBe(true)
|
||||
expect(Ignore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
const watcher = createWrapper({
|
||||
subscribe: async (
|
||||
_directory: string,
|
||||
_callback: (...args: unknown[]) => unknown,
|
||||
options: { ignoreGlobs?: string[] },
|
||||
) => {
|
||||
ignoreGlobs = options.ignoreGlobs ?? []
|
||||
},
|
||||
})
|
||||
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
|
||||
const patterns = ignoreGlobs.map((source) => new RegExp(source))
|
||||
|
||||
for (const path of [
|
||||
"nested/node_modules",
|
||||
"nested/node_modules/package/index.js",
|
||||
"nested/.git",
|
||||
"nested/.git/HEAD",
|
||||
"nested/dist",
|
||||
"nested/dist/index.js",
|
||||
]) {
|
||||
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
|
||||
}
|
||||
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -149,14 +149,12 @@ describeWatcher("LocationWatcher", () => {
|
||||
const update = yield* watcher
|
||||
.subscribe({ path: target, type: "file" })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* fs.writeFileString(sibling, "sibling")
|
||||
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
|
||||
Effect.repeat(Schedule.spaced("10 millis")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(event.valueOrUndefined?.path).toBe(target)
|
||||
yield* fs.writeFileString(sibling, "sibling")
|
||||
yield* fs.writeFileString(target, "target")
|
||||
|
||||
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
@@ -199,24 +197,6 @@ describeWatcher("LocationWatcher", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
|
||||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
@@ -13,7 +12,3 @@ describe("MCP errors", () => {
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
@@ -126,38 +126,6 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tool = (description: string) =>
|
||||
Tool.make({
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
const plugin = define({
|
||||
id: "grouped-tools",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
|
||||
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
|
||||
yield* ctx.tool
|
||||
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context_7_look_up",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
||||
@@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -73,6 +73,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const events = yield* EventV2.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = SessionV2.ID.make("ses_manual_compaction")
|
||||
const userMessage = {
|
||||
@@ -108,7 +109,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
),
|
||||
)
|
||||
|
||||
const delta = yield* events
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
|
||||
@@ -562,9 +562,9 @@ describe("SessionV2.create", () => {
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
|
||||
expect(events).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(events[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.model.selected", data: { model } }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the closed wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
retryAfterMs: 123,
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves structured permission rejection data without inventing resources", () => {
|
||||
const rejected = new PermissionV2.RejectedError({ permission: "external_directory", resources: [] })
|
||||
expect(toSessionError(rejected)).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: external_directory",
|
||||
permission: "external_directory",
|
||||
resources: [],
|
||||
})
|
||||
expect(toSessionError(new ToolFailure({ message: rejected.message, error: rejected }))).toEqual({
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: external_directory",
|
||||
permission: "external_directory",
|
||||
resources: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { terminal } from "@opencode-ai/core/session/execution/local"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Effect, Exit } from "effect"
|
||||
|
||||
describe("SessionExecutionLocal lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "unknown", message: storage.message },
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
|
||||
const interrupted = Effect.runSyncExit(Effect.interrupt)
|
||||
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
|
||||
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
|
||||
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
|
||||
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,6 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no
|
||||
const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
const assistantRow = (
|
||||
@@ -96,7 +95,7 @@ describe("SessionProjector", () => {
|
||||
})
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
to: boundary,
|
||||
})
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
@@ -239,7 +238,6 @@ describe("SessionProjector", () => {
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -339,7 +337,6 @@ describe("SessionProjector", () => {
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
shell: { command: "pwd", status: "exited", exit: 0 },
|
||||
output: { output: "/project", truncated: false },
|
||||
@@ -432,6 +429,73 @@ describe("SessionProjector", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: first,
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
|
||||
const firstRow = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, first))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
|
||||
expect(decode(projected)).toMatchObject({
|
||||
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
|
||||
})
|
||||
|
||||
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: second,
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.internal", message: "Unavailable" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(decode(rows[0])).not.toHaveProperty("retry")
|
||||
expect(decode(rows[1])).not.toHaveProperty("retry")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
@@ -525,7 +589,6 @@ describe("SessionProjector", () => {
|
||||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
textID: "text-stale",
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
@@ -544,7 +607,7 @@ describe("SessionProjector", () => {
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
|
||||
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
|
||||
@@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
|
||||
Effect.gen(function* () {
|
||||
const failure = new Error("failed")
|
||||
const defect = new Error("defect")
|
||||
const settled: Exit.Exit<void, Error>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
|
||||
})
|
||||
|
||||
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||
@@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
|
||||
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(settled).toHaveLength(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves settlement hook defects while releasing ownership", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const defect = new Error("terminal publication failed")
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.void,
|
||||
settled: () => Effect.die(defect),
|
||||
})
|
||||
|
||||
const exit = yield* coordinator.run("session").pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
|
||||
expect(yield* coordinator.active).toEqual(new Set())
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
|
||||
it.effect("does nothing when interrupted while idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
|
||||
yield* coordinator.interrupt("session")
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* coordinator.run("session")
|
||||
expect(reasons).toEqual([undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) =>
|
||||
Deferred.succeed(settling, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
|
||||
),
|
||||
})
|
||||
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
yield* coordinator.run("session")
|
||||
|
||||
expect(reasons).toEqual([undefined, undefined])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
const reasons: Array<string | undefined> = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session")
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
yield* Deferred.await(interrupted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
expect(reasons).toEqual(["user"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
let starts = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
@@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
|
||||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
@@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
|
||||
yield* Deferred.await(secondStarted)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
expect(starts).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const idle = yield* Deferred.make<void>()
|
||||
let drains = 0
|
||||
let starts = 0
|
||||
const settled: Exit.Exit<void, never>[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
||||
drain: () =>
|
||||
@@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
|
||||
settled: (_key, exit) =>
|
||||
Effect.sync(() => void settled.push(exit)).pipe(
|
||||
Effect.andThen(Deferred.succeed(idle, undefined)),
|
||||
@@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
|
||||
yield* Deferred.await(idle)
|
||||
|
||||
expect(drains).toBe(2)
|
||||
expect(starts).toBe(1)
|
||||
expect(settled).toHaveLength(1)
|
||||
expect(Exit.isSuccess(settled[0]!)).toBe(true)
|
||||
}),
|
||||
|
||||
@@ -28,17 +28,14 @@ describe("toLLMMessages", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
assistant("empty", []),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
|
||||
assistant("empty-reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
|
||||
]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
|
||||
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
|
||||
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
|
||||
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
|
||||
assistant("reasoning", [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
@@ -158,12 +155,11 @@ Recent work
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
|
||||
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
state: { signature: "sig_1" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
@@ -208,11 +204,9 @@ Recent work
|
||||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { continuation: "hosted-call" },
|
||||
providerResultState: { continuation: "hosted-result" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
@@ -225,7 +219,8 @@ Recent work
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
executed: true,
|
||||
providerState: { continuation: "failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
@@ -245,7 +240,7 @@ Recent work
|
||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
@@ -260,14 +255,14 @@ Recent work
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-call" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
providerMetadata: { provider: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
@@ -276,14 +271,14 @@ Recent work
|
||||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
@@ -317,9 +312,8 @@ Recent work
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
@@ -332,7 +326,7 @@ Recent work
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -348,19 +342,16 @@ Recent work
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-failed",
|
||||
text: "Partial thought",
|
||||
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
||||
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "call_failed" } },
|
||||
resultMetadata: { openai: { itemId: "result_failed" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "call_failed" },
|
||||
providerResultState: { itemId: "result_failed" },
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
@@ -420,19 +411,16 @@ Recent work
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
state: { signature: "sig_old" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
executed: true,
|
||||
providerState: { itemId: "hosted-old-model" },
|
||||
providerResultState: { itemId: "hosted-old-model" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
@@ -446,11 +434,9 @@ Recent work
|
||||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
executed: false,
|
||||
providerState: { call: "old" },
|
||||
providerResultState: { result: "old" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
|
||||
@@ -47,6 +47,7 @@ const capture = () => {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
},
|
||||
provider: "openai",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -90,7 +91,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
||||
})
|
||||
})
|
||||
|
||||
test("provider-executed success retains its compatibility result", async () => {
|
||||
test("provider-executed success retains its raw provider result", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||
@@ -98,6 +99,19 @@ test("provider-executed success retains its compatibility result", async () => {
|
||||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||
state: { itemId: "reasoning" },
|
||||
})
|
||||
})
|
||||
|
||||
test("binary failure emits no success event", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
@@ -114,7 +128,7 @@ test("binary failure emits no success event", async () => {
|
||||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("old success event data containing result still decodes", () => {
|
||||
test("success event data can carry a provider-executed result", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
@@ -122,7 +136,7 @@ test("old success event data containing result still decodes", () => {
|
||||
structured: { type: "media", mime: "image/png" },
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
||||
provider: { executed: false },
|
||||
executed: true,
|
||||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
})
|
||||
@@ -135,3 +149,37 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
test("content-filter finish fails a contentless step", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" })))
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
})
|
||||
expect(publisher.stepSettlement()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
Effect.forEach(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text" }),
|
||||
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
],
|
||||
(event) => publisher.publish(event),
|
||||
{ discard: true },
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
|
||||
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
|
||||
expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter" },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Model,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
@@ -26,6 +28,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
@@ -61,7 +64,8 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -368,6 +372,20 @@ const providerUnavailable = () =>
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -455,7 +473,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
LLMEvent.textStart({ id }),
|
||||
...chunks.map((text) => LLMEvent.textDelta({ id, text })),
|
||||
]
|
||||
const expectedContent = { type: "text", id, text }
|
||||
const expectedContent = { type: "text", text }
|
||||
return {
|
||||
delta: SessionEvent.Text.Delta,
|
||||
partialEvents,
|
||||
@@ -475,7 +493,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
LLMEvent.reasoningStart({ id }),
|
||||
...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
|
||||
]
|
||||
const expectedContent = { type: "reasoning", id, text }
|
||||
const expectedContent = { type: "reasoning", text }
|
||||
return {
|
||||
delta: SessionEvent.Reasoning.Delta,
|
||||
partialEvents,
|
||||
@@ -510,6 +528,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
requests.length = 0
|
||||
const session = yield* SessionV2.Service
|
||||
const prompt = `Stream ${kind}`
|
||||
const chunks = Array.from({ length: 32 }, (_, index) => `${index},`)
|
||||
@@ -542,6 +561,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
||||
const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
requests.length = 0
|
||||
const session = yield* SessionV2.Service
|
||||
const prompt = `Fail after ${kind}`
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
|
||||
@@ -555,10 +575,11 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
content: [fixture.expectedContent],
|
||||
},
|
||||
])
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
@@ -583,7 +604,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
|
||||
@@ -1369,7 +1390,7 @@ describe("SessionRunnerLLM", () => {
|
||||
overflow(),
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -1415,7 +1436,7 @@ describe("SessionRunnerLLM", () => {
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
const context = yield* session.context(sessionID)
|
||||
@@ -1552,7 +1573,7 @@ describe("SessionRunnerLLM", () => {
|
||||
finish: "tool-calls",
|
||||
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
|
||||
content: [
|
||||
{ type: "reasoning", id: "reasoning-1", text: "Think" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-error",
|
||||
@@ -1560,14 +1581,16 @@ describe("SessionRunnerLLM", () => {
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
error: { type: "unknown", message: "Denied" },
|
||||
error: { type: "tool.execution", message: "Denied" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-provider",
|
||||
name: "web_search",
|
||||
provider: { executed: true, metadata: { fake: { source: "provider" } } },
|
||||
executed: true,
|
||||
providerState: { source: "provider" },
|
||||
providerResultState: { source: "provider" },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "hello" },
|
||||
@@ -1637,7 +1660,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1696,15 +1719,24 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning-anthropic" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
||||
LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-anthropic",
|
||||
providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.reasoningStart({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
providerMetadata: {
|
||||
fake: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
openai: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: {
|
||||
fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
openai: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
@@ -1717,11 +1749,11 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Encrypted thought",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1732,11 +1764,11 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Encrypted thought",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1756,14 +1788,14 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "hosted-search" } },
|
||||
providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
|
||||
providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
@@ -1783,7 +1815,7 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "hosted-search" } },
|
||||
providerMetadata: { fake: { itemId: "hosted-search" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
@@ -1791,7 +1823,7 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
|
||||
providerMetadata: { fake: { blockType: "web_search_tool_result" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1980,7 +2012,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Run once" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Once" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2352,7 +2384,7 @@ describe("SessionRunnerLLM", () => {
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
response = []
|
||||
streamFailure = providerUnavailable()
|
||||
streamFailure = invalidRequest()
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
|
||||
@@ -2402,9 +2434,8 @@ describe("SessionRunnerLLM", () => {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
tool: "echo",
|
||||
input: { text: "stale" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
@@ -2420,7 +2451,10 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-interrupted",
|
||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.stale", message: "Tool execution interrupted", name: "echo" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2462,9 +2496,9 @@ describe("SessionRunnerLLM", () => {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
tool: "web_search",
|
||||
input: { query: "stale" },
|
||||
provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } },
|
||||
executed: true,
|
||||
state: { itemId: "call-hosted-interrupted" },
|
||||
})
|
||||
requests.length = 0
|
||||
response = []
|
||||
@@ -2477,7 +2511,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "tool-call",
|
||||
id: "call-hosted-interrupted",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
|
||||
providerMetadata: { fake: { itemId: "call-hosted-interrupted" } },
|
||||
},
|
||||
{ type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
|
||||
])
|
||||
@@ -2663,7 +2697,7 @@ describe("SessionRunnerLLM", () => {
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
response = []
|
||||
streamFailure = providerUnavailable()
|
||||
streamFailure = invalidRequest()
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
|
||||
@@ -2726,7 +2760,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2769,7 +2803,7 @@ describe("SessionRunnerLLM", () => {
|
||||
id: "call-defect",
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
|
||||
error: { type: "unknown", message: "unexpected tool defect" },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2812,13 +2846,80 @@ describe("SessionRunnerLLM", () => {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: expect.stringContaining("Tool execution failed: Failed to encode tool output"),
|
||||
message: expect.stringContaining("Failed to encode tool output"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves permission rejection and stops before continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
permissionfail: Tool.make({
|
||||
description: "Reject a permission",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
new ToolFailure({
|
||||
message: "Permission rejected: edit",
|
||||
error: new PermissionV2.RejectedError({ permission: "edit", resources: ["src/index.ts"] }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Reject permission" }), resume: false })
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })],
|
||||
]
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: edit",
|
||||
permission: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-permission",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission rejected: edit",
|
||||
permission: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2860,7 +2961,8 @@ describe("SessionRunnerLLM", () => {
|
||||
const exit = yield* Fiber.join(run)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
if (exit._tag === "Failure")
|
||||
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBeInstanceOf(UserInterruptedError)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Ask then stop" },
|
||||
@@ -2870,7 +2972,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-question",
|
||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2942,7 +3044,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-before-interrupt",
|
||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2983,7 +3085,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt provider" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
|
||||
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
|
||||
yield* session.interrupt(sessionID)
|
||||
@@ -3018,12 +3120,12 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-await-interrupt",
|
||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3146,12 +3248,12 @@ describe("SessionRunnerLLM", () => {
|
||||
streamStarted = undefined
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail durably" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
|
||||
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -3165,16 +3267,43 @@ describe("SessionRunnerLLM", () => {
|
||||
requests.length = 0
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail before step" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
|
||||
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects content-filter finishes as visible terminal failures", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Blocked response" }), resume: false })
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial" }),
|
||||
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
LLMEvent.finish({ reason: "content-filter" }),
|
||||
]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.content-filter" },
|
||||
content: [{ type: "text", text: "Partial" }],
|
||||
},
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover context overflow after durable assistant output", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
@@ -3189,7 +3318,7 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -3209,18 +3338,146 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
|
||||
const failure = providerUnavailable()
|
||||
const failure = invalidRequest()
|
||||
responseStream = Stream.fail(failure)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail raw stream durably" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
|
||||
{ type: "assistant", finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries eligible pre-output failures after exponential backoff", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry transport" }), resume: false })
|
||||
requests.length = 0
|
||||
responseStream = Stream.fail(providerUnavailable())
|
||||
response = fragmentFixture("text", "retry-success", ["Recovered"]).completeEvents
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("1999 millis")
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes).toContain("session.retry.scheduled.1")
|
||||
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a larger provider retry-after delay", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry rate limit" }), resume: false })
|
||||
requests.length = 0
|
||||
responseStream = Stream.fail(rateLimited(5_000))
|
||||
response = fragmentFixture("text", "retry-after-success", ["Recovered"]).completeEvents
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("4999 millis")
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(run)
|
||||
expect(requests).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops after five total retry attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Exhaust retries" }), resume: false })
|
||||
requests.length = 0
|
||||
streamFailure = providerUnavailable()
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
||||
yield* TestClock.adjust(delay)
|
||||
while (requests.length < index + 2) yield* Effect.yieldNow
|
||||
}
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure)
|
||||
expect(requests).toHaveLength(5)
|
||||
|
||||
const database = (yield* Database.Service).db
|
||||
const retries = yield* database
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.retry.scheduled.1"))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(retries.map((event) => event.data)).toMatchObject([
|
||||
{ attempt: 2, at: 2_000 },
|
||||
{ attempt: 3, at: 6_000 },
|
||||
{ attempt: 4, at: 14_000 },
|
||||
{ attempt: 5, at: 30_000 },
|
||||
])
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("counts retry attempts against the agent step allowance", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Bound retries by steps" }), resume: false })
|
||||
requests.length = 0
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.fail(failure)
|
||||
streamFailure = failure
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (requests.length < 1) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
|
||||
expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not retry non-eligible provider failures", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not retry" }), resume: false })
|
||||
requests.length = 0
|
||||
const failure = invalidRequest()
|
||||
streamFailure = failure
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
@@ -3239,7 +3496,7 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(executions.slice(executionCount)).toEqual(["settled"])
|
||||
@@ -3264,7 +3521,7 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
@@ -3311,6 +3568,7 @@ describe("SessionRunnerLLM", () => {
|
||||
prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
|
||||
resume: false,
|
||||
})
|
||||
requests.length = 0
|
||||
const failure = providerUnavailable()
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable([
|
||||
@@ -3326,20 +3584,21 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail hosted tool on raw failure" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps interleaved assistant text blocks separate", () =>
|
||||
it.effect("rejects a second text start before the open fragment ends", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -3352,9 +3611,31 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
]
|
||||
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("text start before end: text-2")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects sequential text fragments as separate content parts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
|
||||
|
||||
responses = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "First" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-1" }),
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-2" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
@@ -3367,8 +3648,8 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "text", id: "text-1", text: "First" },
|
||||
{ type: "text", id: "text-2", text: "Second" },
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
|
||||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
content: content("complete"),
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||
@@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
provider: { executed: false },
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||
state: {
|
||||
|
||||
@@ -107,6 +107,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreIntegration.Ref, Integration.Ref],
|
||||
[coreLocation.Ref, Location.Ref],
|
||||
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
|
||||
[coreLLM.FinishReason, LLM.FinishReason],
|
||||
[coreLLM.ToolTextContent, LLM.ToolTextContent],
|
||||
[coreLLM.ToolFileContent, LLM.ToolFileContent],
|
||||
[coreLLM.ToolContent, LLM.ToolContent],
|
||||
@@ -147,7 +148,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreSessionInput.Delivery, SessionInput.Delivery],
|
||||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||
[coreSessionMessage.ID, SessionMessage.ID],
|
||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
||||
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||
[coreSessionMessage.User, SessionMessage.User],
|
||||
@@ -197,7 +198,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
test("shared record schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
|
||||
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
|
||||
|
||||
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
|
||||
|
||||
@@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
||||
@@ -40,7 +40,15 @@ const permission = Layer.succeed(
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
||||
@@ -23,7 +23,17 @@ const permission = Layer.succeed(
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
@@ -72,7 +82,15 @@ describe("QuestionTool", () => {
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
|
||||
).toEqual({
|
||||
result: { type: "error", value: "Permission denied: question" },
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: question",
|
||||
permission: "question",
|
||||
resources: ["*"],
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
deny = false
|
||||
}),
|
||||
|
||||
@@ -81,7 +81,19 @@ const permission = Layer.succeed(
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
||||
import { GrepTool } from "@opencode-ai/core/tool/grep"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_search_tool_test")
|
||||
|
||||
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const call = (name: "glob" | "grep", input: unknown) => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id: `call-${name}`, name, input },
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("search tools", () => {
|
||||
for (const name of ["glob", "grep"] as const) {
|
||||
it.live(`${name} reports a missing search path`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* executeTool(
|
||||
registry,
|
||||
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
|
||||
)
|
||||
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
|
||||
}),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -47,7 +47,15 @@ const permission = Layer.succeed(
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
@@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
|
||||
const session = yield* store.get(id)
|
||||
if (!session) return
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_shell_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
@@ -85,12 +92,10 @@ const executionNode = makeGlobalNode({
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
text: "ok",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
@@ -480,13 +485,9 @@ describe("ShellTool", () => {
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
expect(settled.output?.content[0]).toEqual({
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "The command was moved to the background.",
|
||||
})
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("DO NOT sleep, poll"),
|
||||
text: expect.stringContaining("running in the background"),
|
||||
})
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
|
||||
@@ -55,7 +55,17 @@ describe("SkillTool", () => {
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
||||
@@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
|
||||
}
|
||||
completed.add(sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const textID = "text_subagent_test"
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
@@ -59,12 +58,10 @@ const executionNode = makeGlobalNode({
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
|
||||
@@ -33,7 +33,17 @@ const permission = Layer.succeed(
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
|
||||
@@ -38,7 +38,15 @@ const permission = Layer.succeed(
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new PermissionV2.DeniedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
|
||||
@@ -619,6 +619,11 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||
}
|
||||
|
||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
@@ -810,6 +815,8 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tools = state.tools[item.id]
|
||||
@@ -920,6 +927,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult =>
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||
if (
|
||||
event.type === "response.reasoning_text.delta" ||
|
||||
event.type === "response.reasoning_summary.delta" ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ProviderMetadata } from "@opencode-ai/schema/llm"
|
||||
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
|
||||
|
||||
export { ProviderMetadata }
|
||||
|
||||
@@ -36,7 +36,7 @@ export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
|
||||
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
|
||||
export const FinishReason = LLM.FinishReason
|
||||
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
|
||||
@@ -764,6 +764,35 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// OpenAI's documented stream orders output text within one message item; no
|
||||
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
|
||||
it.effect("closes sequential output messages before starting the next", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses reasoning summary stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -165,8 +165,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.type === "session.execution.settled" &&
|
||||
event.data.outcome === "interrupted" &&
|
||||
event.type === "session.execution.interrupted" &&
|
||||
event.data.reason === "user" &&
|
||||
(interrupted || permissionRejected || questionRejected || formCancelled)
|
||||
) {
|
||||
return
|
||||
@@ -190,11 +190,12 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
|
||||
if (event.type === "session.text.started") {
|
||||
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
|
||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
const started = starts.get(event.data.textID)
|
||||
const started = starts.get("text")
|
||||
starts.delete("text")
|
||||
const part: TextPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
@@ -208,18 +209,19 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
|
||||
if (event.type === "session.reasoning.started") {
|
||||
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
|
||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get(event.data.reasoningID)
|
||||
const started = starts.get("reasoning")
|
||||
starts.delete("reasoning")
|
||||
const part: ReasoningPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "reasoning",
|
||||
text: event.data.text,
|
||||
metadata: event.data.providerMetadata,
|
||||
metadata: event.data.state,
|
||||
time: { start: started?.timestamp ?? time, end: time },
|
||||
}
|
||||
if (emit("reasoning", time, { part })) continue
|
||||
@@ -257,10 +259,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
id: current?.id ?? partID(event.id),
|
||||
timestamp: current?.timestamp ?? time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.tool,
|
||||
tool: current?.tool ?? "tool",
|
||||
input: event.data.input,
|
||||
raw: current?.raw,
|
||||
provider: event.data.provider,
|
||||
provider: { executed: event.data.executed, state: event.data.state },
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -287,7 +289,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: event.data.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
@@ -314,7 +316,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: event.data.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
@@ -349,16 +351,25 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.settled") {
|
||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (!emittedError && !questionRejected && !formCancelled) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
|
||||
if (!emit("error", time, { error })) UI.error(error.message)
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
}
|
||||
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||
if (event.data.reason !== "user" && !emittedError) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
|
||||
if (!emit("error", time, { error })) UI.error(error.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,54 +38,60 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
|
||||
export function legacyTool(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
name: string
|
||||
state: SessionMessageAssistantTool["state"]
|
||||
time: SessionMessageAssistantTool["time"]
|
||||
provider?: SessionMessageAssistantTool["provider"]
|
||||
tool: SessionMessageAssistantTool
|
||||
}): ToolPart {
|
||||
const tool = input.tool
|
||||
const providerCall =
|
||||
tool.executed === undefined && tool.providerState === undefined
|
||||
? undefined
|
||||
: { executed: tool.executed, state: tool.providerState }
|
||||
const providerResult =
|
||||
tool.executed === undefined && tool.providerResultState === undefined
|
||||
? undefined
|
||||
: { executed: tool.executed, state: tool.providerResultState }
|
||||
const base = {
|
||||
id: `prt_${input.callID}`,
|
||||
id: `prt_${tool.id}`,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
type: "tool" as const,
|
||||
callID: input.callID,
|
||||
tool: input.name,
|
||||
callID: tool.id,
|
||||
tool: tool.name,
|
||||
}
|
||||
if (input.state.status === "pending") {
|
||||
if (tool.state.status === "pending") {
|
||||
return {
|
||||
...base,
|
||||
state: { status: "pending", input: {}, raw: input.state.input },
|
||||
state: { status: "pending", input: {}, raw: tool.state.input },
|
||||
}
|
||||
}
|
||||
if (input.state.status === "running") {
|
||||
if (tool.state.status === "running") {
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "running",
|
||||
input: input.state.input,
|
||||
title: input.name,
|
||||
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
|
||||
time: { start: input.time.ran ?? input.time.created },
|
||||
input: tool.state.input,
|
||||
title: tool.name,
|
||||
metadata: { structured: tool.state.structured, content: tool.state.content, providerCall },
|
||||
time: { start: tool.time.ran ?? tool.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (input.state.status === "completed") {
|
||||
if (tool.state.status === "completed") {
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: input.state.input,
|
||||
output: outputText(input.state.content),
|
||||
title: input.name,
|
||||
input: tool.state.input,
|
||||
output: outputText(tool.state.content),
|
||||
title: tool.name,
|
||||
metadata: {
|
||||
structured: input.state.structured,
|
||||
content: input.state.content,
|
||||
outputPaths: input.state.outputPaths,
|
||||
result: input.state.result,
|
||||
providerCall: input.provider,
|
||||
structured: tool.state.structured,
|
||||
content: tool.state.content,
|
||||
outputPaths: tool.state.outputPaths,
|
||||
result: tool.state.result,
|
||||
providerCall,
|
||||
providerResult,
|
||||
},
|
||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
||||
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -93,19 +99,30 @@ export function legacyTool(input: {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: input.state.input,
|
||||
error: input.state.error.message,
|
||||
input: tool.state.input,
|
||||
error: tool.state.error.message,
|
||||
metadata: {
|
||||
structured: input.state.structured,
|
||||
content: input.state.content,
|
||||
result: input.state.result,
|
||||
providerCall: input.provider,
|
||||
structured: tool.state.structured,
|
||||
content: tool.state.content,
|
||||
result: tool.state.result,
|
||||
providerCall,
|
||||
providerResult,
|
||||
},
|
||||
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
||||
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function nextFragmentID(kind: "text" | "reasoning", ordinals: Map<string, number>, messageID: string) {
|
||||
const ordinal = ordinals.get(messageID) ?? 0
|
||||
ordinals.set(messageID, ordinal + 1)
|
||||
return `${kind}:${ordinal}`
|
||||
}
|
||||
|
||||
export function currentFragmentID(kind: "text" | "reasoning", ordinals: Map<string, number>, messageID: string) {
|
||||
return `${kind}:${Math.max(0, (ordinals.get(messageID) ?? 1) - 1)}`
|
||||
}
|
||||
|
||||
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
const status = part.state.status
|
||||
const text =
|
||||
@@ -141,6 +158,7 @@ type ToolTrack = {
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
providerState?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ChildState = {
|
||||
@@ -157,6 +175,8 @@ type ChildState = {
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
textOrdinals: Map<string, number>
|
||||
reasoningOrdinals: Map<string, number>
|
||||
tools: Map<string, ToolTrack>
|
||||
finishedTools: Set<string>
|
||||
messageIDs: Set<string>
|
||||
@@ -225,6 +245,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
const pendingEvents = new Map<string, V2Event[]>()
|
||||
const hydrations = new Map<string, Promise<void>>()
|
||||
let selected: string | undefined
|
||||
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
|
||||
|
||||
const ensureChild = (sessionID: string): ChildState => {
|
||||
const existing = children.get(sessionID)
|
||||
@@ -241,6 +262,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
textOrdinals: new Map(),
|
||||
reasoningOrdinals: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
messageIDs: new Set(),
|
||||
@@ -304,11 +327,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
const part = legacyTool({
|
||||
sessionID: child.sessionID,
|
||||
messageID,
|
||||
callID: item.id,
|
||||
name: item.name,
|
||||
state: item.state,
|
||||
time: item.time,
|
||||
provider: item.provider,
|
||||
tool: item,
|
||||
})
|
||||
if (item.state.status === "pending") return
|
||||
child.callIDs.add(item.id)
|
||||
@@ -327,6 +346,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
child.projectedText.clear()
|
||||
child.reasoning.clear()
|
||||
child.projectedReasoning.clear()
|
||||
child.textOrdinals.clear()
|
||||
child.reasoningOrdinals.clear()
|
||||
child.finishedTools.clear()
|
||||
child.messageIDs.clear()
|
||||
child.callIDs.clear()
|
||||
@@ -337,36 +358,44 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
if (message.type !== "assistant") continue
|
||||
child.messageIDs.add(message.id)
|
||||
let textOrdinal = 0
|
||||
let reasoningOrdinal = 0
|
||||
for (const item of message.content) {
|
||||
if (item.type === "text") {
|
||||
child.text.set(item.id, item.text)
|
||||
child.projectedText.set(item.id, item.text)
|
||||
setFrame(child, `text:${item.id}`, {
|
||||
const id = `text:${textOrdinal++}`
|
||||
const key = fragmentKey(message.id, id)
|
||||
child.text.set(key, item.text)
|
||||
child.projectedText.set(key, item.text)
|
||||
setFrame(child, key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: item.text,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
partID: id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (item.type === "reasoning") {
|
||||
child.reasoning.set(item.id, item.text)
|
||||
child.projectedReasoning.set(item.id, item.text)
|
||||
const id = `reasoning:${reasoningOrdinal++}`
|
||||
const key = fragmentKey(message.id, id)
|
||||
child.reasoning.set(key, item.text)
|
||||
child.projectedReasoning.set(key, item.text)
|
||||
if (input.thinking)
|
||||
setFrame(child, `reasoning:${item.id}`, {
|
||||
setFrame(child, key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${item.text}`,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
partID: id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
childTool(child, item, message.id)
|
||||
}
|
||||
child.textOrdinals.set(message.id, textOrdinal)
|
||||
child.reasoningOrdinals.set(message.id, reasoningOrdinal)
|
||||
if (message.error) {
|
||||
setFrame(child, `error:${message.id}`, {
|
||||
kind: "error",
|
||||
@@ -438,74 +467,90 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
input.emit()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.started") {
|
||||
nextFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const projected = child.projectedText.get(event.data.textID)
|
||||
const id = currentFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
const projected = child.projectedText.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
|
||||
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
|
||||
child.text.set(event.data.textID, next)
|
||||
setFrame(child, `text:${event.data.textID}`, {
|
||||
const next = (child.text.get(key) ?? "") + event.data.delta
|
||||
child.text.set(key, next)
|
||||
setFrame(child, key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: next,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
partID: id,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
child.text.set(event.data.textID, event.data.text)
|
||||
child.projectedText.delete(event.data.textID)
|
||||
setFrame(child, `text:${event.data.textID}`, {
|
||||
const id = currentFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
child.text.set(key, event.data.text)
|
||||
child.projectedText.delete(key)
|
||||
setFrame(child, key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: event.data.text,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
partID: id,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.started") {
|
||||
nextFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
const projected = child.projectedReasoning.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
|
||||
child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
|
||||
child.reasoning.set(event.data.reasoningID, next)
|
||||
const next = (child.reasoning.get(key) ?? "") + event.data.delta
|
||||
child.reasoning.set(key, next)
|
||||
if (!input.thinking) return
|
||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
||||
setFrame(child, key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${next}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
partID: id,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
child.reasoning.set(event.data.reasoningID, event.data.text)
|
||||
child.projectedReasoning.delete(event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
child.reasoning.set(key, event.data.text)
|
||||
child.projectedReasoning.delete(key)
|
||||
if (!input.thinking) return
|
||||
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
||||
setFrame(child, key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${event.data.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
partID: id,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
@@ -517,17 +562,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
if (event.type === "session.tool.called") {
|
||||
const current = child.tools.get(event.data.callID)
|
||||
child.tools.set(event.data.callID, {
|
||||
name: event.data.tool,
|
||||
name: current?.name ?? "tool",
|
||||
input: event.data.input,
|
||||
started: current?.started ?? event.created,
|
||||
providerState: event.data.state,
|
||||
})
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.tool,
|
||||
provider: event.data.provider,
|
||||
name: current?.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { created: current?.started ?? event.created, ran: event.created },
|
||||
},
|
||||
@@ -547,7 +594,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: current?.name ?? "tool",
|
||||
provider: event.data.provider,
|
||||
executed: event.data.executed,
|
||||
providerState: current?.providerState,
|
||||
providerResultState: event.data.resultState,
|
||||
state: failed
|
||||
? {
|
||||
status: "error",
|
||||
@@ -577,7 +626,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
child.textOrdinals.delete(event.data.assistantMessageID)
|
||||
child.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
child.textOrdinals.delete(event.data.assistantMessageID)
|
||||
child.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
||||
kind: "error",
|
||||
source: "system",
|
||||
@@ -589,9 +645,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.settled") {
|
||||
if (event.type === "session.execution.started") {
|
||||
child.status = "running"
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted"
|
||||
) {
|
||||
child.status =
|
||||
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
|
||||
event.type === "session.execution.succeeded"
|
||||
? "completed"
|
||||
: event.type === "session.execution.interrupted"
|
||||
? "cancelled"
|
||||
: "error"
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
}
|
||||
@@ -613,8 +683,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
|
||||
return {
|
||||
main(event) {
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
|
||||
if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
|
||||
@@ -5,13 +5,12 @@ import type {
|
||||
QuestionRequest,
|
||||
QuestionV2Request,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
|
||||
import { createSubagentTracker, currentFragmentID, legacyTool, nextFragmentID, toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -95,6 +94,7 @@ type ToolState = {
|
||||
input: Record<string, unknown>
|
||||
started: number
|
||||
running: boolean
|
||||
providerState?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -106,6 +106,8 @@ type State = {
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
textOrdinals: Map<string, number>
|
||||
reasoningOrdinals: Map<string, number>
|
||||
tools: Map<string, ToolState>
|
||||
finishedTools: Set<string>
|
||||
skillMessages: Set<string>
|
||||
@@ -288,6 +290,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
textOrdinals: new Map(),
|
||||
reasoningOrdinals: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
skillMessages: new Set(),
|
||||
@@ -352,11 +356,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const part = legacyTool({
|
||||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
callID: item.id,
|
||||
name: item.name,
|
||||
state: item.state,
|
||||
time: item.time,
|
||||
provider: item.provider,
|
||||
tool: item,
|
||||
})
|
||||
if (item.state.status === "pending") return
|
||||
if (item.state.status === "running") {
|
||||
@@ -367,6 +367,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
input: item.state.input,
|
||||
started: item.time.ran ?? item.time.created,
|
||||
running: true,
|
||||
providerState: item.providerState,
|
||||
})
|
||||
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
|
||||
return
|
||||
@@ -443,9 +444,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
state.messageIDs.add(message.id)
|
||||
let textOrdinal = 0
|
||||
let reasoningOrdinal = 0
|
||||
for (const item of message.content) {
|
||||
if (item.type === "text") {
|
||||
const key = streamPartKey(message.id, item.id)
|
||||
const id = `text:${textOrdinal++}`
|
||||
const key = streamPartKey(message.id, id)
|
||||
const sent = state.text.get(key)?.length ?? 0
|
||||
state.text.set(key, item.text)
|
||||
if (render) state.projectedText.set(key, item.text)
|
||||
@@ -457,13 +461,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: item.text.slice(sent),
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
continue
|
||||
}
|
||||
if (item.type === "reasoning") {
|
||||
const key = streamPartKey(message.id, item.id)
|
||||
const id = `reasoning:${reasoningOrdinal++}`
|
||||
const key = streamPartKey(message.id, id)
|
||||
const sent = state.reasoning.get(key)?.length ?? 0
|
||||
state.reasoning.set(key, item.text)
|
||||
if (render) state.projectedReasoning.set(key, item.text)
|
||||
@@ -475,13 +480,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: item.id,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
continue
|
||||
}
|
||||
if (render) renderTool(message.id, item)
|
||||
}
|
||||
state.textOrdinals.set(message.id, textOrdinal)
|
||||
state.reasoningOrdinals.set(message.id, reasoningOrdinal)
|
||||
if (render && message.error && !state.errors.has(message.id)) {
|
||||
state.errors.add(message.id)
|
||||
write([
|
||||
@@ -596,8 +603,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (owned) wait.resolve()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.started") {
|
||||
nextFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
||||
const id = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const projected = state.projectedText.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
@@ -613,13 +625,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: event.data.delta,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
||||
const id = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const previous = state.text.get(key) ?? ""
|
||||
if (event.data.text.length > previous.length)
|
||||
write([
|
||||
@@ -629,15 +642,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: event.data.text.slice(previous.length),
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
state.text.set(key, event.data.text)
|
||||
state.projectedText.delete(key)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.started") {
|
||||
nextFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const projected = state.projectedReasoning.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
@@ -654,13 +672,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
||||
const id = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const previous = state.reasoning.get(key) ?? ""
|
||||
if (input.thinking && event.data.text.length > previous.length)
|
||||
write([
|
||||
@@ -670,7 +689,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.reasoningID,
|
||||
partID: id,
|
||||
},
|
||||
])
|
||||
state.reasoning.set(key, event.data.text)
|
||||
@@ -693,8 +712,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const item: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.tool,
|
||||
provider: event.data.provider,
|
||||
name: current?.name ?? "tool",
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { created: current?.started ?? event.created, ran: event.created },
|
||||
}
|
||||
@@ -709,7 +729,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: current?.name ?? "tool",
|
||||
provider: event.data.provider,
|
||||
executed: event.data.executed,
|
||||
providerState: current?.providerState,
|
||||
providerResultState: event.data.resultState,
|
||||
state: failed
|
||||
? {
|
||||
status: "error",
|
||||
@@ -753,6 +775,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
state.textOrdinals.delete(event.data.assistantMessageID)
|
||||
state.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
const total =
|
||||
event.data.tokens.input +
|
||||
event.data.tokens.output +
|
||||
@@ -761,32 +785,45 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
event.data.tokens.cache.write
|
||||
const usage = total > 0 ? total.toLocaleString() : ""
|
||||
write([], {
|
||||
phase: event.data.finish === "tool-calls" ? "running" : "idle",
|
||||
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
state.textOrdinals.delete(event.data.assistantMessageID)
|
||||
state.reasoningOrdinals.delete(event.data.assistantMessageID)
|
||||
state.errors.add(event.data.assistantMessageID)
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.settled") {
|
||||
if (event.type === "session.execution.started") {
|
||||
write([], { phase: "running" })
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted"
|
||||
) {
|
||||
write([], { phase: "idle", status: "" })
|
||||
const current = state.wait
|
||||
if (!current || (!current.promoted && !current.interrupted)) return
|
||||
state.wait = undefined
|
||||
if (current.interrupted) {
|
||||
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
if (event.data.outcome === "failure") {
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (current.failureRendered) {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
|
||||
current.reject(new Error(errorMessage(event.data.error)))
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
|
||||
return
|
||||
}
|
||||
current.resolve()
|
||||
@@ -1046,6 +1083,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.projectedText.clear()
|
||||
state.reasoning.clear()
|
||||
state.projectedReasoning.clear()
|
||||
state.textOrdinals.clear()
|
||||
state.reasoningOrdinals.clear()
|
||||
state.tools.clear()
|
||||
state.finishedTools.clear()
|
||||
state.skillMessages.clear()
|
||||
|
||||
@@ -32,11 +32,20 @@ function prompted(inputID: string): V2Event {
|
||||
}
|
||||
|
||||
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
||||
if (outcome === "interrupted")
|
||||
return {
|
||||
id: "evt_interrupted",
|
||||
created: 0,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
}
|
||||
return {
|
||||
id: "evt_settled",
|
||||
id: "evt_succeeded",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome },
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_1" },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thin
|
||||
})
|
||||
}
|
||||
|
||||
function shell(status: "running" | "exited" = "running") {
|
||||
return {
|
||||
id: "sh_1",
|
||||
status,
|
||||
command: "pwd",
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/shell-output",
|
||||
metadata: {},
|
||||
time: { started: 1, completed: status === "exited" ? 2 : undefined },
|
||||
}
|
||||
}
|
||||
|
||||
function assistant(id: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "message.updated",
|
||||
@@ -332,9 +345,7 @@ describe("run session data", () => {
|
||||
type: "session.shell.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
callID: "call-1",
|
||||
command: "pwd",
|
||||
shell: shell(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -342,10 +353,10 @@ describe("run session data", () => {
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
phase: "start",
|
||||
partID: "shell:call-1",
|
||||
partID: "shell:sh_1",
|
||||
tool: "bash",
|
||||
shell: {
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
command: "pwd",
|
||||
},
|
||||
}),
|
||||
@@ -356,9 +367,8 @@ describe("run session data", () => {
|
||||
type: "session.shell.ended",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
output: "/tmp/demo\n",
|
||||
shell: shell("exited"),
|
||||
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -366,12 +376,12 @@ describe("run session data", () => {
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
phase: "progress",
|
||||
partID: "shell:call-1",
|
||||
partID: "shell:sh_1",
|
||||
tool: "bash",
|
||||
text: "/tmp/demo\n",
|
||||
toolState: "completed",
|
||||
shell: {
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
command: "pwd",
|
||||
},
|
||||
}),
|
||||
@@ -383,9 +393,7 @@ describe("run session data", () => {
|
||||
type: "session.shell.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
callID: "call-1",
|
||||
command: "pwd",
|
||||
shell: shell(),
|
||||
},
|
||||
}).data
|
||||
|
||||
@@ -395,7 +403,7 @@ describe("run session data", () => {
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
@@ -412,9 +420,8 @@ describe("run session data", () => {
|
||||
type: "session.shell.ended",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
output: "/tmp/demo\n",
|
||||
shell: shell("exited"),
|
||||
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
|
||||
},
|
||||
}).data
|
||||
|
||||
@@ -424,7 +431,7 @@ describe("run session data", () => {
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
@@ -449,7 +456,7 @@ describe("run session data", () => {
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
@@ -466,9 +473,7 @@ describe("run session data", () => {
|
||||
type: "session.shell.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
callID: "call-1",
|
||||
command: "pwd",
|
||||
shell: shell(),
|
||||
},
|
||||
}).commits,
|
||||
).toEqual([])
|
||||
@@ -478,7 +483,7 @@ describe("run session data", () => {
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
callID: "sh_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
@@ -500,9 +505,8 @@ describe("run session data", () => {
|
||||
type: "session.shell.ended",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
output: "/tmp/demo\n",
|
||||
shell: shell("exited"),
|
||||
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
|
||||
},
|
||||
}).commits,
|
||||
).toEqual([])
|
||||
|
||||
@@ -214,15 +214,15 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
textID: "txt_1",
|
||||
delta: "answer",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await turn
|
||||
|
||||
@@ -269,8 +269,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
@@ -363,8 +364,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
@@ -460,8 +462,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
@@ -709,7 +712,7 @@ describe("V2 mini transport", () => {
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", id: "txt_1", text: "the answer" }],
|
||||
content: [{ type: "text", text: "the answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
@@ -728,7 +731,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
textID: "txt_1",
|
||||
delta: "answer",
|
||||
},
|
||||
})
|
||||
@@ -741,7 +743,7 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("scopes repeated text and reasoning ids by assistant message", async () => {
|
||||
test("scopes text and reasoning ordinals by assistant message", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
@@ -754,8 +756,8 @@ describe("V2 mini transport", () => {
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", id: "reasoning-0", text: "second thought" },
|
||||
{ type: "text", id: "text-0", text: "second answer" },
|
||||
{ type: "reasoning", text: "second thought" },
|
||||
{ type: "text", text: "second answer" },
|
||||
],
|
||||
time: { created: 4, completed: 5 },
|
||||
},
|
||||
@@ -765,8 +767,8 @@ describe("V2 mini transport", () => {
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "reasoning", id: "reasoning-0", text: "first thought" },
|
||||
{ type: "text", id: "text-0", text: "first answer" },
|
||||
{ type: "reasoning", text: "first thought" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
@@ -814,7 +816,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
reasoningID: "reasoning_1",
|
||||
text: "considering",
|
||||
},
|
||||
})
|
||||
@@ -824,6 +825,52 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("renders a live tool start when the call begins", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
events.push({
|
||||
id: "evt_tool_input",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
callID: "call_read",
|
||||
name: "read",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_tool_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: durable("ses_1", 1),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
callID: "call_read",
|
||||
input: { path: "README.md" },
|
||||
executed: false,
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "tool", phase: "start", partID: "prt_call_read", tool: "read" }),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("resolves an interrupted turn even when promotion never arrived", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
@@ -865,8 +912,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
})
|
||||
await turn
|
||||
|
||||
@@ -931,8 +979,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await turn
|
||||
|
||||
@@ -993,8 +1042,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
})
|
||||
await turn
|
||||
|
||||
@@ -1219,8 +1269,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({
|
||||
@@ -1296,8 +1347,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok(undefined) as never
|
||||
@@ -1366,8 +1418,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_unrelated_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
await Bun.sleep(0)
|
||||
@@ -1387,8 +1440,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_skill_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_1", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await turn
|
||||
|
||||
@@ -1509,7 +1563,6 @@ describe("V2 mini transport", () => {
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child_a",
|
||||
textID: "txt_child",
|
||||
delta: "child answer",
|
||||
},
|
||||
})
|
||||
@@ -1519,8 +1572,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_child_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_child", outcome: "success" },
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_child"),
|
||||
data: { sessionID: "ses_child" },
|
||||
})
|
||||
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
|
||||
await transport.close()
|
||||
@@ -1576,8 +1630,9 @@ describe("V2 mini transport", () => {
|
||||
events.push({
|
||||
id: "evt_child_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_child", outcome: "interrupted" },
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_child"),
|
||||
data: { sessionID: "ses_child", reason: "user" },
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
resolveGet?.()
|
||||
@@ -1633,6 +1688,18 @@ describe("V2 mini transport", () => {
|
||||
},
|
||||
})
|
||||
// Parent's background subagent tool.success adopts the child mid-discovery.
|
||||
events.push({
|
||||
id: "evt_parent_input",
|
||||
created: 0,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_parent_a",
|
||||
callID: "call_sub",
|
||||
name: "subagent",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_parent_call",
|
||||
created: 0,
|
||||
@@ -1642,9 +1709,8 @@ describe("V2 mini transport", () => {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_parent_a",
|
||||
callID: "call_sub",
|
||||
tool: "subagent",
|
||||
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
|
||||
provider: { executed: true },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
@@ -1658,15 +1724,16 @@ describe("V2 mini transport", () => {
|
||||
callID: "call_sub",
|
||||
structured: { sessionID: "ses_child", status: "running", output: "" },
|
||||
content: [],
|
||||
provider: { executed: true },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
// The settled event arrives after adoption, so it applies directly.
|
||||
events.push({
|
||||
id: "evt_child_settled",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: { sessionID: "ses_child", outcome: "interrupted" },
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_child"),
|
||||
data: { sessionID: "ses_child", reason: "shutdown" },
|
||||
})
|
||||
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID: "text-1",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -115,7 +114,6 @@ test.skip("text ended populates assistant text content", () => {
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
@@ -123,7 +121,7 @@ test.skip("text ended populates assistant text content", () => {
|
||||
|
||||
expect(state.messages[0]?.type).toBe("assistant")
|
||||
if (state.messages[0]?.type !== "assistant") return
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
|
||||
expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
|
||||
})
|
||||
|
||||
test.skip("tool completion stores completed timestamp", () => {
|
||||
@@ -176,9 +174,9 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: true, metadata: { fake: { source: "provider" } } },
|
||||
executed: true,
|
||||
state: { source: "provider" },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -195,7 +193,8 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
callID,
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/tmp" }],
|
||||
provider: { executed: true, metadata: { fake: { status: "done" } } },
|
||||
executed: true,
|
||||
resultState: { status: "done" },
|
||||
},
|
||||
} satisfies SessionEvent.Event),
|
||||
)
|
||||
@@ -205,7 +204,11 @@ test.skip("tool completion stores completed timestamp", () => {
|
||||
expect(state.messages[0].content[0]?.type).toBe("tool")
|
||||
if (state.messages[0].content[0]?.type !== "tool") return
|
||||
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
|
||||
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
|
||||
expect(state.messages[0].content[0]).toMatchObject({
|
||||
executed: true,
|
||||
providerState: { source: "provider" },
|
||||
providerResultState: { status: "done" },
|
||||
})
|
||||
})
|
||||
|
||||
test("compaction events reduce to compaction message only when completed", () => {
|
||||
|
||||
@@ -186,17 +186,8 @@ export const validateName = (name: string) =>
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
|
||||
Object.entries(tools).map(([name, tool]) => {
|
||||
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
return {
|
||||
key: parent === undefined ? normalized : `${parent}_${normalized}`,
|
||||
name: normalized,
|
||||
group: parent,
|
||||
tool,
|
||||
}
|
||||
})
|
||||
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>) =>
|
||||
Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const)
|
||||
|
||||
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
tool: Definition<Input, Output>,
|
||||
@@ -244,15 +235,7 @@ export interface ToolExecuteAfterEvent {
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly group?: string
|
||||
readonly deferred?: boolean
|
||||
}
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: RegisterOptions,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export { Revert } from "./revert.js"
|
||||
export { Session } from "./session.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
export { SessionInput } from "./session-input.js"
|
||||
export { SessionError } from "./session-error.js"
|
||||
export { SessionMessage } from "./session-message.js"
|
||||
export { Shell } from "./shell.js"
|
||||
export { Skill } from "./skill.js"
|
||||
|
||||
@@ -8,6 +8,9 @@ export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schem
|
||||
})
|
||||
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
|
||||
|
||||
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
|
||||
export type FinishReason = typeof FinishReason.Type
|
||||
|
||||
export interface ToolTextContent extends Schema.Schema.Type<typeof ToolTextContent> {}
|
||||
export const ToolTextContent = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
const Message = { message: Schema.String }
|
||||
|
||||
const ProviderRateLimit = Schema.Struct({
|
||||
type: Schema.Literal("provider.rate-limit"),
|
||||
...Message,
|
||||
retryAfterMs: Schema.Finite.pipe(optional),
|
||||
})
|
||||
const ProviderAuth = Schema.Struct({ type: Schema.Literal("provider.auth"), ...Message })
|
||||
const ProviderQuota = Schema.Struct({ type: Schema.Literal("provider.quota"), ...Message })
|
||||
const ProviderContentFilter = Schema.Struct({ type: Schema.Literal("provider.content-filter"), ...Message })
|
||||
const ProviderTransport = Schema.Struct({ type: Schema.Literal("provider.transport"), ...Message })
|
||||
const ProviderInternal = Schema.Struct({ type: Schema.Literal("provider.internal"), ...Message })
|
||||
const ProviderInvalidOutput = Schema.Struct({ type: Schema.Literal("provider.invalid-output"), ...Message })
|
||||
const ProviderInvalidRequest = Schema.Struct({ type: Schema.Literal("provider.invalid-request"), ...Message })
|
||||
const ProviderNoRoute = Schema.Struct({ type: Schema.Literal("provider.no-route"), ...Message })
|
||||
const ProviderUnknown = Schema.Struct({ type: Schema.Literal("provider.unknown"), ...Message })
|
||||
const PermissionRejected = Schema.Struct({
|
||||
type: Schema.Literal("permission.rejected"),
|
||||
...Message,
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
})
|
||||
const ToolUnknown = Schema.Struct({ type: Schema.Literal("tool.unknown"), ...Message, name: Schema.String })
|
||||
const ToolStale = Schema.Struct({
|
||||
type: Schema.Literal("tool.stale"),
|
||||
...Message,
|
||||
name: Schema.String.pipe(optional),
|
||||
})
|
||||
const ToolExecution = Schema.Struct({ type: Schema.Literal("tool.execution"), ...Message })
|
||||
const ToolResultMissing = Schema.Struct({
|
||||
type: Schema.Literal("tool.result-missing"),
|
||||
...Message,
|
||||
callID: Schema.String.pipe(optional),
|
||||
})
|
||||
const Aborted = Schema.Struct({
|
||||
type: Schema.Literal("aborted"),
|
||||
...Message,
|
||||
reason: Schema.Literals(["user", "shutdown", "timeout"]).pipe(optional),
|
||||
})
|
||||
const Unknown = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
...Message,
|
||||
agent: Schema.String.pipe(optional),
|
||||
})
|
||||
|
||||
export const Error = Schema.Union([
|
||||
ProviderRateLimit,
|
||||
ProviderAuth,
|
||||
ProviderQuota,
|
||||
ProviderContentFilter,
|
||||
ProviderTransport,
|
||||
ProviderInternal,
|
||||
ProviderInvalidOutput,
|
||||
ProviderInvalidRequest,
|
||||
ProviderNoRoute,
|
||||
ProviderUnknown,
|
||||
PermissionRejected,
|
||||
ToolUnknown,
|
||||
ToolStale,
|
||||
ToolExecution,
|
||||
ToolResultMissing,
|
||||
Aborted,
|
||||
Unknown,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.StructuredError" })
|
||||
export type Error = typeof Error.Type
|
||||
@@ -3,16 +3,18 @@ export * as SessionEvent from "./session-event.js"
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { Event } from "./event.js"
|
||||
import { ProviderMetadata, ToolContent } from "./llm.js"
|
||||
import { ToolContent } from "./llm.js"
|
||||
import { FinishReason } from "./llm.js"
|
||||
import { Delivery } from "./session-delivery.js"
|
||||
import { Model } from "./model.js"
|
||||
import { NonNegativeInt, RelativePath } from "./schema.js"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { FileAttachment, Prompt } from "./prompt.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
import { Revert } from "./revert.js"
|
||||
import { Shell as ShellSchema } from "./shell.js"
|
||||
import { SessionError } from "./session-error.js"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
@@ -41,16 +43,6 @@ const options = {
|
||||
version: 1,
|
||||
},
|
||||
} as const
|
||||
const stepSettlementOptions = {
|
||||
durable: {
|
||||
aggregate: "sessionID",
|
||||
version: 1,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const UnknownError = SessionMessage.UnknownError
|
||||
export type UnknownError = SessionMessage.UnknownError
|
||||
|
||||
export const AgentSelected = Event.durable({
|
||||
type: "session.agent.selected",
|
||||
...options,
|
||||
@@ -120,15 +112,27 @@ export const PromptAdmitted = Event.durable({
|
||||
})
|
||||
export type PromptAdmitted = typeof PromptAdmitted.Type
|
||||
|
||||
export const ExecutionSettled = Event.ephemeral({
|
||||
type: "session.execution.settled",
|
||||
schema: {
|
||||
...Base,
|
||||
outcome: Schema.Literals(["success", "failure", "interrupted"]),
|
||||
error: UnknownError.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type ExecutionSettled = typeof ExecutionSettled.Type
|
||||
export namespace Execution {
|
||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||
export type Started = typeof Started.Type
|
||||
|
||||
export const Succeeded = Event.durable({ type: "session.execution.succeeded", ...options, schema: Base })
|
||||
export type Succeeded = typeof Succeeded.Type
|
||||
|
||||
export const Failed = Event.durable({
|
||||
type: "session.execution.failed",
|
||||
...options,
|
||||
schema: { ...Base, error: SessionError.Error },
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
|
||||
export const Interrupted = Event.durable({
|
||||
type: "session.execution.interrupted",
|
||||
...options,
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) },
|
||||
})
|
||||
export type Interrupted = typeof Interrupted.Type
|
||||
}
|
||||
|
||||
export const ContextUpdated = Event.durable({
|
||||
type: "session.context.updated",
|
||||
@@ -204,11 +208,11 @@ export namespace Step {
|
||||
|
||||
export const Ended = Event.durable({
|
||||
type: "session.step.ended",
|
||||
...stepSettlementOptions,
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
finish: Schema.String,
|
||||
finish: FinishReason,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -227,11 +231,11 @@ export namespace Step {
|
||||
|
||||
export const Failed = Event.durable({
|
||||
type: "session.step.failed",
|
||||
...stepSettlementOptions,
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
error: UnknownError,
|
||||
error: SessionError.Error,
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
@@ -244,7 +248,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
@@ -255,7 +258,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -267,7 +269,6 @@ export namespace Text {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
textID: Schema.String,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -281,8 +282,7 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
@@ -293,7 +293,6 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -305,9 +304,8 @@ export namespace Reasoning {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
reasoningID: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
@@ -357,12 +355,9 @@ export namespace Tool {
|
||||
...options,
|
||||
schema: {
|
||||
...ToolBase,
|
||||
tool: Schema.String,
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
}),
|
||||
executed: Schema.Boolean,
|
||||
state: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Called = typeof Called.Type
|
||||
@@ -391,10 +386,8 @@ export namespace Tool {
|
||||
content: Schema.Array(ToolContent),
|
||||
outputPaths: Schema.Array(Schema.String).pipe(optional),
|
||||
result: Schema.Unknown.pipe(optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
}),
|
||||
executed: Schema.Boolean,
|
||||
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
@@ -404,39 +397,27 @@ export namespace Tool {
|
||||
...options,
|
||||
schema: {
|
||||
...ToolBase,
|
||||
error: UnknownError,
|
||||
error: SessionError.Error,
|
||||
result: Schema.Unknown.pipe(optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
}),
|
||||
executed: Schema.Boolean,
|
||||
resultState: SessionMessage.ProviderState.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
}
|
||||
|
||||
export const RetryError = Schema.Struct({
|
||||
message: Schema.String,
|
||||
statusCode: Schema.Finite.pipe(optional),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
responseBody: Schema.String.pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
}).annotate({
|
||||
identifier: "session.retry.error",
|
||||
})
|
||||
export interface RetryError extends Schema.Schema.Type<typeof RetryError> {}
|
||||
|
||||
export const Retried = Event.durable({
|
||||
type: "session.retried",
|
||||
export const RetryScheduled = Event.durable({
|
||||
type: "session.retry.scheduled",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
attempt: Schema.Finite,
|
||||
error: RetryError,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
attempt: PositiveInt,
|
||||
at: NonNegativeInt,
|
||||
error: SessionError.Error,
|
||||
},
|
||||
})
|
||||
export type Retried = typeof Retried.Type
|
||||
export type RetryScheduled = typeof RetryScheduled.Type
|
||||
|
||||
export namespace Compaction {
|
||||
export const Started = Event.durable({
|
||||
@@ -481,7 +462,7 @@ export namespace RevertEvent {
|
||||
export const Committed = Event.durable({
|
||||
type: "session.revert.committed",
|
||||
...options,
|
||||
schema: { ...Base, messageID: SessionMessage.ID },
|
||||
schema: { ...Base, to: SessionMessage.ID },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -493,7 +474,10 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
PromptPromoted,
|
||||
PromptAdmitted,
|
||||
ExecutionSettled,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
Execution.Interrupted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Skill.Activated,
|
||||
@@ -515,7 +499,7 @@ export const Definitions = Event.inventory(
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Failed,
|
||||
Retried,
|
||||
RetryScheduled,
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
|
||||
@@ -2,14 +2,16 @@ export * as SessionMessage from "./session-message.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ProviderMetadata, ToolContent } from "./llm.js"
|
||||
import { ToolContent } from "./llm.js"
|
||||
import { Model } from "./model.js"
|
||||
import { FileAttachment, Prompt } from "./prompt.js"
|
||||
import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js"
|
||||
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { Event } from "./event.js"
|
||||
import { Shell as ShellSchema } from "./shell.js"
|
||||
import { FinishReason } from "./llm.js"
|
||||
import { SessionError } from "./session-error.js"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
||||
Schema.brand("Session.Message.ID"),
|
||||
@@ -20,18 +22,17 @@ export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface UnknownError extends Schema.Schema.Type<typeof UnknownError> {}
|
||||
export const UnknownError = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
message: Schema.String,
|
||||
}).annotate({ identifier: "Session.Error.Unknown" })
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
|
||||
}
|
||||
|
||||
export const ProviderState = Schema.Record(Schema.String, Schema.Unknown).annotate({
|
||||
identifier: "Session.Message.ProviderState",
|
||||
})
|
||||
export type ProviderState = typeof ProviderState.Type
|
||||
|
||||
export interface AgentSelected extends Schema.Schema.Type<typeof AgentSelected> {}
|
||||
export const AgentSelected = Schema.Struct({
|
||||
...Base,
|
||||
@@ -44,7 +45,6 @@ export const ModelSelected = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.Literal("model-switched"),
|
||||
model: Model.Ref,
|
||||
previous: Model.Ref.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.ModelSelected" })
|
||||
|
||||
export interface User extends Schema.Schema.Type<typeof User> {}
|
||||
@@ -123,7 +123,7 @@ export const ToolStateError = Schema.Struct({
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
content: ToolContent.pipe(Schema.Array),
|
||||
structured: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: UnknownError,
|
||||
error: SessionError.Error,
|
||||
result: Schema.Unknown.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.ToolState.Error" })
|
||||
|
||||
@@ -137,11 +137,9 @@ export const AssistantTool = Schema.Struct({
|
||||
type: Schema.Literal("tool"),
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: ProviderMetadata.pipe(optional),
|
||||
resultMetadata: ProviderMetadata.pipe(optional),
|
||||
}).pipe(optional),
|
||||
executed: Schema.Boolean.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
providerResultState: ProviderState.pipe(optional),
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
@@ -154,16 +152,14 @@ export const AssistantTool = Schema.Struct({
|
||||
export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
|
||||
export const AssistantText = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
||||
|
||||
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
||||
export const AssistantReasoning = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(optional),
|
||||
state: ProviderState.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||
@@ -175,6 +171,13 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning,
|
||||
)
|
||||
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
|
||||
|
||||
export interface AssistantRetry extends Schema.Schema.Type<typeof AssistantRetry> {}
|
||||
export const AssistantRetry = Schema.Struct({
|
||||
attempt: PositiveInt,
|
||||
at: DateTimeUtcFromMillis,
|
||||
error: SessionError.Error,
|
||||
}).annotate({ identifier: "Session.Message.Assistant.Retry" })
|
||||
|
||||
export interface Assistant extends Schema.Schema.Type<typeof Assistant> {}
|
||||
export const Assistant = Schema.Struct({
|
||||
...Base,
|
||||
@@ -187,7 +190,7 @@ export const Assistant = Schema.Struct({
|
||||
end: Schema.String.pipe(optional),
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
}).pipe(optional),
|
||||
finish: Schema.String.pipe(optional),
|
||||
finish: FinishReason.pipe(optional),
|
||||
cost: Schema.Finite.pipe(optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -195,7 +198,8 @@ export const Assistant = Schema.Struct({
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
|
||||
}).pipe(optional),
|
||||
error: UnknownError.pipe(optional),
|
||||
error: SessionError.Error.pipe(optional),
|
||||
retry: AssistantRetry.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
import { Model } from "../src/model.js"
|
||||
@@ -7,6 +7,7 @@ import { Project } from "../src/project.js"
|
||||
import { Pty } from "../src/pty.js"
|
||||
import { Question } from "../src/question.js"
|
||||
import { Session } from "../src/session.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { SessionTodo } from "../src/session-todo.js"
|
||||
import { optional } from "../src/schema.js"
|
||||
|
||||
@@ -67,4 +68,25 @@ describe("contract hygiene", () => {
|
||||
expect(source).not.toContain("Schema.Any")
|
||||
expect(source).not.toContain("Schema.mutable")
|
||||
})
|
||||
|
||||
test("assistant content keeps only domain identities", () => {
|
||||
expect(SessionMessage.AssistantText.make({ type: "text", text: "hello" })).toEqual({
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
expect(
|
||||
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }),
|
||||
).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } })
|
||||
expect(
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "search",
|
||||
executed: true,
|
||||
providerState: { itemId: "item_1" },
|
||||
state: { status: "pending", input: "" },
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
).not.toHaveProperty("provider")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ import { FileSystemV1 } from "../src/filesystem-v1.js"
|
||||
import { IdeEvent } from "../src/ide-event.js"
|
||||
import { McpEvent } from "../src/mcp-event.js"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
import { SessionID } from "../src/session-id.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { SessionTodo } from "../src/session-todo.js"
|
||||
import { SessionV1 } from "../src/session-v1.js"
|
||||
import { WorkspaceEvent } from "../src/workspace-event.js"
|
||||
@@ -99,6 +101,10 @@ describe("public event manifest", () => {
|
||||
"session.forked.1",
|
||||
"session.prompt.promoted.1",
|
||||
"session.prompt.admitted.1",
|
||||
"session.execution.started.1",
|
||||
"session.execution.succeeded.1",
|
||||
"session.execution.failed.1",
|
||||
"session.execution.interrupted.1",
|
||||
"session.context.updated.1",
|
||||
"session.synthetic.1",
|
||||
"session.skill.activated.1",
|
||||
@@ -117,7 +123,7 @@ describe("public event manifest", () => {
|
||||
"session.tool.failed.1",
|
||||
"session.reasoning.started.1",
|
||||
"session.reasoning.ended.1",
|
||||
"session.retried.1",
|
||||
"session.retry.scheduled.1",
|
||||
"session.compaction.started.1",
|
||||
"session.compaction.ended.1",
|
||||
"session.revert.staged.1",
|
||||
@@ -130,4 +136,32 @@ describe("public event manifest", () => {
|
||||
)
|
||||
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps simplified session fragment and tool payloads on durable version 1", () => {
|
||||
const sessionID = SessionID.make("ses_test")
|
||||
const assistantMessageID = SessionMessage.ID.make("msg_test")
|
||||
const text = SessionEvent.Text.Started.data.make({ sessionID, assistantMessageID })
|
||||
const reasoning = SessionEvent.Reasoning.Ended.data.make({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
text: "thought",
|
||||
state: { signature: "sig" },
|
||||
})
|
||||
const tool = SessionEvent.Tool.Called.data.make({
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call_test",
|
||||
input: {},
|
||||
executed: true,
|
||||
state: { itemId: "item_test" },
|
||||
})
|
||||
|
||||
expect(text).not.toHaveProperty("textID")
|
||||
expect(reasoning).not.toHaveProperty("reasoningID")
|
||||
expect(reasoning).not.toHaveProperty("providerMetadata")
|
||||
expect(tool).not.toHaveProperty("tool")
|
||||
expect(tool).not.toHaveProperty("provider")
|
||||
expect(SessionEvent.Text.Started.durable?.version).toBe(1)
|
||||
expect(SessionEvent.Tool.Called.durable?.version).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { LLM, SessionError } from "../src/index.js"
|
||||
|
||||
describe("SessionError", () => {
|
||||
test("exports one identified closed union", () => {
|
||||
expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError")
|
||||
expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"])
|
||||
})
|
||||
|
||||
test("round trips every closed error type through JSON", () => {
|
||||
const values: SessionError.Error[] = [
|
||||
{ type: "provider.rate-limit", message: "Slow down", retryAfterMs: 2_500 },
|
||||
{ type: "provider.auth", message: "Authentication failed" },
|
||||
{ type: "provider.quota", message: "Quota exhausted" },
|
||||
{ type: "provider.content-filter", message: "Response blocked" },
|
||||
{ type: "provider.transport", message: "Connection failed" },
|
||||
{ type: "provider.internal", message: "Provider failed" },
|
||||
{ type: "provider.invalid-output", message: "Malformed response" },
|
||||
{ type: "provider.invalid-request", message: "Invalid request" },
|
||||
{ type: "provider.no-route", message: "No route" },
|
||||
{ type: "provider.unknown", message: "Unknown provider failure" },
|
||||
{ type: "permission.rejected", message: "Permission rejected", permission: "read", resources: ["a"] },
|
||||
{ type: "tool.unknown", message: "Unknown tool", name: "missing" },
|
||||
{ type: "tool.stale", message: "Stale tool", name: "old" },
|
||||
{ type: "tool.execution", message: "Tool failed" },
|
||||
{ type: "tool.result-missing", message: "Missing result", callID: "call_1" },
|
||||
{ type: "aborted", message: "Interrupted", reason: "user" },
|
||||
{ type: "unknown", message: "Unexpected", agent: "build" },
|
||||
]
|
||||
const codec = Schema.fromJsonString(SessionError.Error)
|
||||
|
||||
for (const value of values) {
|
||||
const encoded = Schema.encodeSync(codec)(value)
|
||||
expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(value)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects unknown types and missing messages", () => {
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.timeout", message: "Timeout" }),
|
||||
).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
test("FinishReason is the closed browser-safe provider set", () => {
|
||||
const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const
|
||||
expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons])
|
||||
expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow()
|
||||
})
|
||||
@@ -60,7 +60,7 @@ if (schemas) {
|
||||
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
||||
for (const name of Object.keys(schemas)) {
|
||||
if (
|
||||
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
|
||||
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
|
||||
name,
|
||||
) &&
|
||||
!reachable.has(name)
|
||||
@@ -100,17 +100,23 @@ await createClient({
|
||||
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
|
||||
const generatedTypes = await Bun.file(generatedTypesPath).text()
|
||||
if (
|
||||
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
|
||||
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
|
||||
generatedTypes,
|
||||
)
|
||||
) {
|
||||
throw new Error("Session history generated duplicate Session event variants")
|
||||
}
|
||||
const logTypesPatched = generatedTypes.replace(
|
||||
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
|
||||
"$1number",
|
||||
)
|
||||
if (logTypesPatched === generatedTypes) {
|
||||
const duplicateSessionErrorStart = generatedTypes.indexOf("export type SessionStructuredError2 =")
|
||||
const duplicateSessionErrorEnd = generatedTypes.indexOf("\n\nexport type ", duplicateSessionErrorStart + 1)
|
||||
if (duplicateSessionErrorStart === -1 || duplicateSessionErrorEnd === -1) {
|
||||
throw new Error("Session structured error duplicate prune did not apply")
|
||||
}
|
||||
const sessionErrorTypesPatched =
|
||||
generatedTypes.slice(0, duplicateSessionErrorStart) + generatedTypes.slice(duplicateSessionErrorEnd + 2)
|
||||
const logTypesPatched = sessionErrorTypesPatched
|
||||
.replaceAll("SessionStructuredError2", "SessionStructuredError")
|
||||
.replace(/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/, "$1number")
|
||||
if (logTypesPatched === sessionErrorTypesPatched) {
|
||||
throw new Error("Session log numeric query patch did not apply")
|
||||
}
|
||||
const sessionListTypesPatched = logTypesPatched.replace(
|
||||
@@ -128,12 +134,15 @@ if (sessionMessagesTypesPatched === sessionListTypesPatched) {
|
||||
throw new Error("Session messages numeric query patch did not apply")
|
||||
}
|
||||
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
|
||||
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/,
|
||||
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStream(?:V2)?;?\s*\};?/,
|
||||
"$1V2Event",
|
||||
)
|
||||
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
|
||||
throw new Error("Event subscribe response patch did not apply")
|
||||
}
|
||||
if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) {
|
||||
throw new Error("Session structured error generated a name-mangled duplicate")
|
||||
}
|
||||
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
||||
|
||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||
|
||||
@@ -24,7 +24,10 @@ export type Event =
|
||||
| EventSessionForked
|
||||
| EventSessionPromptPromoted
|
||||
| EventSessionPromptAdmitted
|
||||
| EventSessionExecutionSettled
|
||||
| EventSessionExecutionStarted
|
||||
| EventSessionExecutionSucceeded
|
||||
| EventSessionExecutionFailed
|
||||
| EventSessionExecutionInterrupted
|
||||
| EventSessionContextUpdated
|
||||
| EventSessionSynthetic
|
||||
| EventSessionSkillActivated
|
||||
@@ -46,7 +49,7 @@ export type Event =
|
||||
| EventSessionToolProgress
|
||||
| EventSessionToolSuccess
|
||||
| EventSessionToolFailed
|
||||
| EventSessionRetried
|
||||
| EventSessionRetryScheduled
|
||||
| EventSessionCompactionStarted
|
||||
| EventSessionCompactionDelta
|
||||
| EventSessionCompactionEnded
|
||||
@@ -919,11 +922,32 @@ export type GlobalEvent = {
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.execution.settled"
|
||||
type: "session.execution.started"
|
||||
properties: {
|
||||
sessionID: string
|
||||
outcome: "success" | "failure" | "interrupted"
|
||||
error?: SessionErrorUnknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.execution.succeeded"
|
||||
properties: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.execution.failed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.execution.interrupted"
|
||||
properties: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -994,7 +1018,7 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -1015,7 +1039,7 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1024,7 +1048,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1033,7 +1056,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -1043,7 +1065,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -1053,8 +1074,7 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1063,7 +1083,6 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -1073,9 +1092,8 @@ export type GlobalEvent = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1115,14 +1133,11 @@ export type GlobalEvent = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1151,10 +1166,8 @@ export type GlobalEvent = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1164,21 +1177,21 @@ export type GlobalEvent = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1227,7 +1240,7 @@ export type GlobalEvent = {
|
||||
type: "session.revert.committed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1723,6 +1736,10 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionForked
|
||||
| SyncEventSessionPromptPromoted
|
||||
| SyncEventSessionPromptAdmitted
|
||||
| SyncEventSessionExecutionStarted
|
||||
| SyncEventSessionExecutionSucceeded
|
||||
| SyncEventSessionExecutionFailed
|
||||
| SyncEventSessionExecutionInterrupted
|
||||
| SyncEventSessionContextUpdated
|
||||
| SyncEventSessionSynthetic
|
||||
| SyncEventSessionSkillActivated
|
||||
@@ -1741,7 +1758,7 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionToolProgress
|
||||
| SyncEventSessionToolSuccess
|
||||
| SyncEventSessionToolFailed
|
||||
| SyncEventSessionRetried
|
||||
| SyncEventSessionRetryScheduled
|
||||
| SyncEventSessionCompactionStarted
|
||||
| SyncEventSessionCompactionEnded
|
||||
| SyncEventSessionRevertStaged
|
||||
@@ -2891,6 +2908,10 @@ export type SessionDurableEvent =
|
||||
| SessionForked
|
||||
| SessionPromptPromoted
|
||||
| SessionPromptAdmitted
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
| SessionExecutionInterrupted
|
||||
| SessionContextUpdated
|
||||
| SessionSynthetic
|
||||
| SessionSkillActivated
|
||||
@@ -2909,7 +2930,7 @@ export type SessionDurableEvent =
|
||||
| SessionToolProgress
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetried
|
||||
| SessionRetryScheduled
|
||||
| SessionCompactionStarted
|
||||
| SessionCompactionEnded
|
||||
| SessionRevertStaged
|
||||
@@ -3032,7 +3053,10 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionPromptPromoted
|
||||
| SessionPromptAdmitted
|
||||
| SessionExecutionSettled
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
| SessionExecutionInterrupted
|
||||
| SessionContextUpdated
|
||||
| SessionSynthetic
|
||||
| SessionSkillActivated
|
||||
@@ -3054,7 +3078,7 @@ export type V2Event =
|
||||
| SessionToolProgress
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetried
|
||||
| SessionRetryScheduled
|
||||
| SessionCompactionStarted
|
||||
| SessionCompactionDelta
|
||||
| SessionCompactionEnded
|
||||
@@ -3271,15 +3295,86 @@ export type PromptAgentAttachment = {
|
||||
source?: PromptSource
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown = {
|
||||
type: "unknown"
|
||||
message: string
|
||||
}
|
||||
export type SessionStructuredError =
|
||||
| {
|
||||
type: "provider.rate-limit"
|
||||
message: string
|
||||
retryAfterMs?: number
|
||||
}
|
||||
| {
|
||||
type: "provider.auth"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.quota"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.content-filter"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.transport"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.internal"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.invalid-output"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.invalid-request"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.no-route"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "provider.unknown"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "permission.rejected"
|
||||
message: string
|
||||
permission: string
|
||||
resources: Array<string>
|
||||
}
|
||||
| {
|
||||
type: "tool.unknown"
|
||||
message: string
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
type: "tool.stale"
|
||||
message: string
|
||||
name?: string
|
||||
}
|
||||
| {
|
||||
type: "tool.execution"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "tool.result-missing"
|
||||
message: string
|
||||
callID?: string
|
||||
}
|
||||
| {
|
||||
type: "aborted"
|
||||
message: string
|
||||
reason?: "user" | "shutdown" | "timeout"
|
||||
}
|
||||
| {
|
||||
type: "unknown"
|
||||
message: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ToolTextContent = {
|
||||
@@ -3296,19 +3391,6 @@ export type ToolFileContent = {
|
||||
|
||||
export type LlmToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionRetryError = {
|
||||
message: string
|
||||
statusCode?: number
|
||||
isRetryable: boolean
|
||||
responseHeaders?: {
|
||||
[key: string]: string
|
||||
}
|
||||
responseBody?: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FileDiff = {
|
||||
path: string
|
||||
status: "added" | "modified" | "deleted"
|
||||
@@ -3728,6 +3810,64 @@ export type SyncEventSessionPromptAdmitted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionExecutionStarted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.execution.started.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionExecutionSucceeded = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.execution.succeeded.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionExecutionFailed = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.execution.failed.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionExecutionInterrupted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.execution.interrupted.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionContextUpdated = {
|
||||
type: "sync"
|
||||
id: string
|
||||
@@ -3843,7 +3983,7 @@ export type SyncEventSessionStepEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -3871,7 +4011,7 @@ export type SyncEventSessionStepFailed = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3887,7 +4027,6 @@ export type SyncEventSessionTextStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3903,7 +4042,6 @@ export type SyncEventSessionTextEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -3920,8 +4058,7 @@ export type SyncEventSessionReasoningStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3937,9 +4074,8 @@ export type SyncEventSessionReasoningEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3990,14 +4126,11 @@ export type SyncEventSessionToolCalled = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4040,10 +4173,8 @@ export type SyncEventSessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4060,28 +4191,28 @@ export type SyncEventSessionToolFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionRetried = {
|
||||
export type SyncEventSessionRetryScheduled = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.retried.1"
|
||||
type: "session.retry.scheduled.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4157,7 +4288,7 @@ export type SyncEventSessionRevertCommitted = {
|
||||
aggregateID: string
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4310,7 +4441,6 @@ export type SessionMessageModelSelected = {
|
||||
}
|
||||
type: "model-switched"
|
||||
model: ModelRef
|
||||
previous?: ModelRef
|
||||
}
|
||||
|
||||
export type SessionMessageUser = {
|
||||
@@ -4387,15 +4517,13 @@ export type SessionMessageShell = {
|
||||
|
||||
export type SessionMessageAssistantText = {
|
||||
type: "text"
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantReasoning = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
@@ -4441,7 +4569,7 @@ export type SessionMessageToolStateError = {
|
||||
structured: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
@@ -4449,11 +4577,9 @@ export type SessionMessageAssistantTool = {
|
||||
type: "tool"
|
||||
id: string
|
||||
name: string
|
||||
provider?: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
resultMetadata?: LlmProviderMetadata
|
||||
}
|
||||
executed?: boolean
|
||||
providerState?: SessionMessageProviderState
|
||||
providerResultState?: SessionMessageProviderState
|
||||
state:
|
||||
| SessionMessageToolStatePending
|
||||
| SessionMessageToolStateRunning
|
||||
@@ -4467,6 +4593,12 @@ export type SessionMessageAssistantTool = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantRetry = {
|
||||
attempt: number
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionMessageAssistant = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -4485,7 +4617,7 @@ export type SessionMessageAssistant = {
|
||||
end?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
finish?: string
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
@@ -4496,7 +4628,8 @@ export type SessionMessageAssistant = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: SessionErrorUnknown
|
||||
error?: SessionStructuredError
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction = {
|
||||
@@ -4668,6 +4801,80 @@ export type SessionPromptAdmitted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.started"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionSucceeded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.succeeded"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.failed"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionInterrupted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.interrupted"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionContextUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -4812,7 +5019,7 @@ export type SessionStepEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -4844,7 +5051,7 @@ export type SessionStepFailed = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4864,7 +5071,6 @@ export type SessionTextStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4884,7 +5090,6 @@ export type SessionTextEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -4905,8 +5110,7 @@ export type SessionReasoningStarted = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4926,9 +5130,8 @@ export type SessionReasoningEnded = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4991,14 +5194,11 @@ export type SessionToolCalled = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5049,10 +5249,8 @@ export type SessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5073,22 +5271,20 @@ export type SessionToolFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRetried = {
|
||||
export type SessionRetryScheduled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
@@ -5097,8 +5293,10 @@ export type SessionRetried = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5194,7 +5392,7 @@ export type SessionRevertCommitted = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5725,21 +5923,6 @@ export type MessagePartRemoved = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionSettled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.settled"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
outcome: "success" | "failure" | "interrupted"
|
||||
error?: SessionErrorUnknown
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -5751,7 +5934,6 @@ export type SessionTextDelta = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -5767,7 +5949,6 @@ export type SessionReasoningDelta = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6827,13 +7008,37 @@ export type EventSessionPromptAdmitted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionExecutionSettled = {
|
||||
export type EventSessionExecutionStarted = {
|
||||
id: string
|
||||
type: "session.execution.settled"
|
||||
type: "session.execution.started"
|
||||
properties: {
|
||||
sessionID: string
|
||||
outcome: "success" | "failure" | "interrupted"
|
||||
error?: SessionErrorUnknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionExecutionSucceeded = {
|
||||
id: string
|
||||
type: "session.execution.succeeded"
|
||||
properties: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionExecutionFailed = {
|
||||
id: string
|
||||
type: "session.execution.failed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionExecutionInterrupted = {
|
||||
id: string
|
||||
type: "session.execution.interrupted"
|
||||
properties: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6911,7 +7116,7 @@ export type EventSessionStepEnded = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -6933,7 +7138,7 @@ export type EventSessionStepFailed = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6943,7 +7148,6 @@ export type EventSessionTextStarted = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6953,7 +7157,6 @@ export type EventSessionTextDelta = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6964,7 +7167,6 @@ export type EventSessionTextEnded = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -6975,8 +7177,7 @@ export type EventSessionReasoningStarted = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6986,7 +7187,6 @@ export type EventSessionReasoningDelta = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -6997,9 +7197,8 @@ export type EventSessionReasoningEnded = {
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7043,14 +7242,11 @@ export type EventSessionToolCalled = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7081,10 +7277,8 @@ export type EventSessionToolSuccess = {
|
||||
content: Array<LlmToolContent>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7095,22 +7289,22 @@ export type EventSessionToolFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionRetried = {
|
||||
export type EventSessionRetryScheduled = {
|
||||
id: string
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
properties: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7165,7 +7359,7 @@ export type EventSessionRevertCommitted = {
|
||||
type: "session.revert.committed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7986,7 +8180,6 @@ export type SessionMessageModelSelected2 = {
|
||||
}
|
||||
type: "model-switched"
|
||||
model: ModelRef2
|
||||
previous?: ModelRef2
|
||||
}
|
||||
|
||||
export type SessionMessageUser2 = {
|
||||
@@ -8081,21 +8274,17 @@ export type SessionMessageShell2 = {
|
||||
|
||||
export type SessionMessageAssistantText2 = {
|
||||
type: "text"
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata2 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState2 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantReasoning2 = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata2
|
||||
state?: SessionMessageProviderState2
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
@@ -8146,11 +8335,6 @@ export type SessionMessageToolStateCompleted2 = {
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown2 = {
|
||||
type: "unknown"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError2 = {
|
||||
status: "error"
|
||||
input: {
|
||||
@@ -8160,7 +8344,7 @@ export type SessionMessageToolStateError2 = {
|
||||
structured: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
@@ -8168,11 +8352,9 @@ export type SessionMessageAssistantTool2 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
name: string
|
||||
provider?: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata2
|
||||
resultMetadata?: LlmProviderMetadata2
|
||||
}
|
||||
executed?: boolean
|
||||
providerState?: SessionMessageProviderState2
|
||||
providerResultState?: SessionMessageProviderState2
|
||||
state:
|
||||
| SessionMessageToolStatePending2
|
||||
| SessionMessageToolStateRunning2
|
||||
@@ -8186,6 +8368,12 @@ export type SessionMessageAssistantTool2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantRetry2 = {
|
||||
attempt: number
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionMessageAssistant2 = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -8204,7 +8392,7 @@ export type SessionMessageAssistant2 = {
|
||||
end?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
finish?: string
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
@@ -8215,7 +8403,8 @@ export type SessionMessageAssistant2 = {
|
||||
write: number
|
||||
}
|
||||
}
|
||||
error?: SessionErrorUnknown2
|
||||
error?: SessionStructuredError
|
||||
retry?: SessionMessageAssistantRetry2
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction2 = {
|
||||
@@ -8390,6 +8579,80 @@ export type SessionPromptAdmitted2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.started"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionSucceeded2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.succeeded"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionFailed2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.failed"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionInterrupted2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.interrupted"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "user" | "shutdown" | "superseded"
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionContextUpdated2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -8552,7 +8815,7 @@ export type SessionStepEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -8584,7 +8847,7 @@ export type SessionStepFailed2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8604,7 +8867,6 @@ export type SessionTextStarted2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8624,15 +8886,12 @@ export type SessionTextEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata3 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState3 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted2 = {
|
||||
@@ -8651,15 +8910,12 @@ export type SessionReasoningStarted2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: LlmProviderMetadata3
|
||||
state?: SessionMessageProviderState3
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata4 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState4 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionReasoningEnded2 = {
|
||||
@@ -8678,9 +8934,8 @@ export type SessionReasoningEnded2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: LlmProviderMetadata4
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8726,10 +8981,8 @@ export type SessionToolInputEnded2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata5 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState5 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolCalled2 = {
|
||||
@@ -8749,14 +9002,11 @@ export type SessionToolCalled2 = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
tool: string
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata5
|
||||
}
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8784,10 +9034,8 @@ export type SessionToolProgress2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata6 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState6 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolSuccess2 = {
|
||||
@@ -8813,17 +9061,13 @@ export type SessionToolSuccess2 = {
|
||||
content: Array<LlmToolContent2>
|
||||
outputPaths?: Array<string>
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata6
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
export type LlmProviderMetadata7 = {
|
||||
[key: string]: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type SessionMessageProviderState7 = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionToolFailed2 = {
|
||||
@@ -8843,35 +9087,20 @@ export type SessionToolFailed2 = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionErrorUnknown2
|
||||
error: SessionStructuredError
|
||||
result?: unknown
|
||||
provider: {
|
||||
executed: boolean
|
||||
metadata?: LlmProviderMetadata7
|
||||
}
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRetryError2 = {
|
||||
message: string
|
||||
statusCode?: number
|
||||
isRetryable: boolean
|
||||
responseHeaders?: {
|
||||
[key: string]: string
|
||||
}
|
||||
responseBody?: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRetried2 = {
|
||||
export type SessionRetryScheduled2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.retried"
|
||||
type: "session.retry.scheduled"
|
||||
durable: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
@@ -8880,8 +9109,10 @@ export type SessionRetried2 = {
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
attempt: number
|
||||
error: SessionRetryError2
|
||||
at: number
|
||||
error: SessionStructuredError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8977,7 +9208,7 @@ export type SessionRevertCommitted2 = {
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
to: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8989,6 +9220,10 @@ export type SessionDurableEventV2 =
|
||||
| SessionForked2
|
||||
| SessionPromptPromoted2
|
||||
| SessionPromptAdmitted2
|
||||
| SessionExecutionStarted2
|
||||
| SessionExecutionSucceeded2
|
||||
| SessionExecutionFailed2
|
||||
| SessionExecutionInterrupted2
|
||||
| SessionContextUpdated2
|
||||
| SessionSynthetic2
|
||||
| SessionSkillActivated2
|
||||
@@ -9007,7 +9242,7 @@ export type SessionDurableEventV2 =
|
||||
| SessionToolProgress2
|
||||
| SessionToolSuccess2
|
||||
| SessionToolFailed2
|
||||
| SessionRetried2
|
||||
| SessionRetryScheduled2
|
||||
| SessionCompactionStarted2
|
||||
| SessionCompactionEnded2
|
||||
| SessionRevertStaged2
|
||||
@@ -10225,21 +10460,6 @@ export type MessagePartRemoved2 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionExecutionSettled2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.execution.settled"
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
sessionID: string
|
||||
outcome: "success" | "failure" | "interrupted"
|
||||
error?: SessionErrorUnknown2
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionTextDelta2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -10251,7 +10471,6 @@ export type SessionTextDelta2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -10267,7 +10486,6 @@ export type SessionReasoningDelta2 = {
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
}
|
||||
@@ -11158,7 +11376,10 @@ export type V2EventV2 =
|
||||
| SessionForked2
|
||||
| SessionPromptPromoted2
|
||||
| SessionPromptAdmitted2
|
||||
| SessionExecutionSettled2
|
||||
| SessionExecutionStarted2
|
||||
| SessionExecutionSucceeded2
|
||||
| SessionExecutionFailed2
|
||||
| SessionExecutionInterrupted2
|
||||
| SessionContextUpdated2
|
||||
| SessionSynthetic2
|
||||
| SessionSkillActivated2
|
||||
@@ -11180,7 +11401,7 @@ export type V2EventV2 =
|
||||
| SessionToolProgress2
|
||||
| SessionToolSuccess2
|
||||
| SessionToolFailed2
|
||||
| SessionRetried2
|
||||
| SessionRetryScheduled2
|
||||
| SessionCompactionStarted2
|
||||
| SessionCompactionDelta2
|
||||
| SessionCompactionEnded2
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
SkillV2Info,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
@@ -51,6 +51,7 @@ type Data = {
|
||||
// session ID in that family, including the key itself once its info arrives.
|
||||
family: Record<string, string[]>
|
||||
status: Record<string, DataSessionStatus>
|
||||
compaction: Partial<Record<string, string>>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
@@ -69,6 +70,20 @@ function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
export function mergeActiveSessionStatus(
|
||||
active: Readonly<Record<string, unknown>>,
|
||||
current: Readonly<Record<string, DataSessionStatus>>,
|
||||
changed: ReadonlyMap<string, number>,
|
||||
revision: number,
|
||||
) {
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
|
||||
)
|
||||
for (const [sessionID, value] of Object.entries(current))
|
||||
if ((changed.get(sessionID) ?? 0) > revision) status[sessionID] = value
|
||||
return status
|
||||
}
|
||||
|
||||
type Mutable<T> =
|
||||
T extends ReadonlyArray<infer U> ? Mutable<U>[] : T extends object ? { -readonly [K in keyof T]: Mutable<T[K]> } : T
|
||||
|
||||
@@ -85,6 +100,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
compaction: {},
|
||||
message: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
@@ -100,15 +116,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
let connectionGeneration = 0
|
||||
let statusChanges: Set<string> | undefined
|
||||
const statusChanged = new Map<string, number>()
|
||||
let statusRevision = 0
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
|
||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
||||
statusChanges?.add(sessionID)
|
||||
setStore("session", "status", sessionID, status)
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -143,14 +154,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||
)
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -242,7 +251,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.model.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
if (!store.session.message[event.data.sessionID]) break
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
@@ -251,35 +259,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
void sdk.api.session
|
||||
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, mutable(item))
|
||||
draft[position] = mutable(item)
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error("Failed to load projected model switch message", error))
|
||||
break
|
||||
case "session.renamed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.prompt.promoted": {
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
const existing = draft[position]
|
||||
if (existing?.type === "user" && existing.metadata?.queued === true) {
|
||||
const existing = position === undefined ? undefined : draft[position]
|
||||
if (existing?.type === "user") {
|
||||
existing.time.created = event.created
|
||||
delete existing.metadata.queued
|
||||
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
if (existing.metadata?.queued === true) {
|
||||
delete existing.metadata.queued
|
||||
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -321,7 +315,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.shell.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
@@ -332,7 +325,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.shell.ended":
|
||||
setSessionStatus(event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.shell(draft, event.data.shell.id)
|
||||
if (!match) return
|
||||
@@ -342,11 +334,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.step.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
if (index.has(event.data.assistantMessageID)) return
|
||||
const position = index.get(event.data.assistantMessageID)
|
||||
const existing = position === undefined ? undefined : draft[position]
|
||||
if (existing?.type === "assistant") {
|
||||
existing.agent = event.data.agent
|
||||
existing.model = event.data.model
|
||||
existing.retry = undefined
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
}
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.created
|
||||
if (currentAssistant) {
|
||||
currentAssistant.retry = undefined
|
||||
currentAssistant.time.completed = event.created
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
@@ -359,7 +364,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.step.ended":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
@@ -378,32 +382,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.data.textID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.textID,
|
||||
)
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.textID,
|
||||
)
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
break
|
||||
@@ -444,7 +442,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.provider = event.data.provider
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
@@ -473,11 +472,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
@@ -496,11 +492,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
@@ -508,41 +501,57 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.reasoningID,
|
||||
)
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.reasoningID,
|
||||
)
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.retried":
|
||||
case "session.compaction.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: event.data.at,
|
||||
error: event.data.error,
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.execution.settled":
|
||||
setSessionStatus(event.data.sessionID, "idle")
|
||||
case "session.execution.started":
|
||||
statusChanged.set(event.data.sessionID, ++statusRevision)
|
||||
setStore("session", "status", event.data.sessionID, "running")
|
||||
break
|
||||
case "session.compaction.started":
|
||||
setStore("session", "compaction", event.data.sessionID, "")
|
||||
break
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
statusChanged.set(event.data.sessionID, ++statusRevision)
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
if (store.session.compaction[event.data.sessionID] !== undefined)
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
break
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
@@ -554,8 +563,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
break
|
||||
case "session.compaction.delta":
|
||||
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
@@ -676,6 +687,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
status(sessionID: string) {
|
||||
return store.session.status[sessionID] ?? "idle"
|
||||
},
|
||||
compaction(sessionID: string) {
|
||||
return store.session.compaction[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||
registerSession(sessionID)
|
||||
@@ -861,6 +875,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapping) return bootstrapping
|
||||
const activeRevision = statusRevision
|
||||
bootstrapping = Promise.allSettled([
|
||||
sdk.api.session
|
||||
.list({
|
||||
@@ -879,6 +894,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
}),
|
||||
sdk.api.session.active().then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
mergeActiveSessionStatus(active.data, store.session.status, statusChanged, activeRevision),
|
||||
)
|
||||
}),
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
@@ -900,30 +922,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return bootstrapping
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
const generation = ++connectionGeneration
|
||||
const changed = new Set<string>()
|
||||
statusChanges = changed
|
||||
void sdk.api.session
|
||||
.active()
|
||||
.then((active) => {
|
||||
if (generation !== connectionGeneration) return
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active.data).map((sessionID) => [sessionID, "running" as const]),
|
||||
)
|
||||
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
|
||||
setStore("session", "status", reconcile(status))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (statusChanges === changed) statusChanges = undefined
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
sdk.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") {
|
||||
refreshActive()
|
||||
void bootstrap()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) {
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const active = new Set<string>()
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
@@ -57,14 +57,13 @@ const tui: TuiPlugin = async (api) => {
|
||||
})
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
|
||||
const ended = (sessionID: string) => {
|
||||
if (!active.has(sessionID)) return
|
||||
active.delete(sessionID)
|
||||
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
@@ -74,28 +73,25 @@ const tui: TuiPlugin = async (api) => {
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.shell.ended", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.step.ended", (event) => {
|
||||
if (event.data.finish === "tool-calls") return
|
||||
ended(event.data.sessionID)
|
||||
})
|
||||
api.event.on("session.step.failed", (event) => {
|
||||
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!active.has(sessionID)) return
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, "Session error", "error")
|
||||
notify(api, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (!active.has(sessionID)) return
|
||||
if (api.state.session.status(sessionID)?.type !== "busy") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { LocationProvider } from "../../context/location"
|
||||
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
||||
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
@@ -915,6 +915,9 @@ export function Session() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={data.session.compaction(route.sessionID)}>
|
||||
{(text) => <CompactionMessage text={text()} />}
|
||||
</Show>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
@@ -1092,7 +1095,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (item?.type !== "assistant") return
|
||||
return item.content.find((part) => part.id === props.partRef.partID)
|
||||
return resolvePart(item, props.partRef.partID)
|
||||
})
|
||||
return (
|
||||
<Show when={part()}>
|
||||
@@ -1132,7 +1135,7 @@ function SessionGroupView(props: {
|
||||
refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = message.content.find((part) => part.id === ref.partID)
|
||||
const part = resolvePart(message, ref.partID)
|
||||
if (part?.type !== "tool") return []
|
||||
return [part]
|
||||
})
|
||||
@@ -1211,6 +1214,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.message.retry}>
|
||||
{(retry) => (
|
||||
<box paddingLeft={3} marginTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
||||
@@ -1231,8 +1243,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
if (props.message.type === "model-switched") return switchLabel(props.message.model, ctx.models())
|
||||
return ""
|
||||
}
|
||||
return <text fg={theme.textMuted}>{text()}</text>
|
||||
@@ -1261,9 +1272,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
|
||||
)
|
||||
}
|
||||
|
||||
function CompactionMessage() {
|
||||
function CompactionMessage(props: { text?: string }) {
|
||||
const { theme } = useTheme()
|
||||
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
|
||||
return (
|
||||
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}>
|
||||
<Show when={props.text}>
|
||||
<text fg={theme.textMuted}>{props.text}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(status: "added" | "modified" | "deleted") {
|
||||
@@ -1544,6 +1561,15 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
||||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.message.retry}>
|
||||
{(retry) => (
|
||||
<box paddingLeft={3} marginTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
@@ -2080,16 +2106,14 @@ function Shell(props: ToolProps) {
|
||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||
})
|
||||
const color = createMemo(() => (permission() ? theme.warning : theme.text))
|
||||
const shellID = createMemo(() => stringValue(props.metadata.shellID))
|
||||
const backgroundRunning = createMemo(() => {
|
||||
const id = shellID()
|
||||
return Boolean(id && data.shell.get(id))
|
||||
const isRunning = createMemo(() => {
|
||||
if (props.part.state.status === "running") return true
|
||||
const shellID = stringValue(props.metadata.shellID)
|
||||
return Boolean(shellID && data.shell.get(shellID))
|
||||
})
|
||||
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
|
||||
const command = createMemo(() => stringValue(props.input.command))
|
||||
const output = createMemo(() => {
|
||||
if (props.part.state.status === "pending") return ""
|
||||
if (shellID()) return ""
|
||||
const content = props.part.state.content[0]
|
||||
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
|
||||
})
|
||||
@@ -2132,11 +2156,6 @@ function Shell(props: ToolProps) {
|
||||
</Spinner>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={shellID()}>
|
||||
<text>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Backgrounded </span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={collapsed().overflow}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
|
||||
@@ -28,24 +28,23 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
return rows
|
||||
return reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
|
||||
}
|
||||
|
||||
function pendingPermissions() {
|
||||
return new Set(
|
||||
createEffect(() => {
|
||||
const pending = new Set(
|
||||
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
|
||||
request.source?.type === "tool" ? [request.source.callID] : [],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingPermissions()
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
partitionPending(draft, pending)
|
||||
draft.forEach((row) => {
|
||||
if (row.type !== "group") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -70,20 +69,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.message
|
||||
.list(sessionID())
|
||||
.flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }]
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
@@ -131,6 +116,20 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}),
|
||||
)
|
||||
|
||||
const removeFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
|
||||
if (index !== -1) draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
|
||||
const latestFragmentRef = (messageID: string, kind: "text" | "reasoning") => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
const ordinal = message?.type === "assistant" ? message.content.filter((part) => part.type === kind).length - 1 : 0
|
||||
return { messageID, partID: `${kind}:${Math.max(0, ordinal)}` }
|
||||
}
|
||||
|
||||
const isQueued = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
return message?.type === "user" && message.metadata?.queued === true
|
||||
@@ -149,6 +148,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.prompt.admitted", input),
|
||||
data.on("session.prompt.promoted", input),
|
||||
data.on("session.context.updated", message),
|
||||
data.on("session.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
@@ -159,25 +159,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
data.on("session.model.selected", message),
|
||||
data.on("session.compaction.ended", message),
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
if (event.data.sessionID === sessionID()) appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
|
||||
}),
|
||||
data.on("session.text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
|
||||
}),
|
||||
data.on("session.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
|
||||
}),
|
||||
data.on("session.reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
|
||||
}),
|
||||
data.on("session.tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||
}),
|
||||
data.on("session.retry.scheduled", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.started", (event) => {
|
||||
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
@@ -201,11 +206,13 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
@@ -213,6 +220,15 @@ export function reduceSessionRows(messages: SessionMessage[]) {
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||
if (tool) return tool
|
||||
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
||||
if (!match) return
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
function isQueuedMessage(message: SessionMessage) {
|
||||
return message.type === "user" && message.metadata?.queued === true
|
||||
}
|
||||
@@ -239,15 +255,6 @@ function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
@@ -34,12 +34,11 @@ export function formatRef(model: { providerID: string; id: string; variant?: str
|
||||
export function switchLabel(
|
||||
model: { providerID: string; id: string; variant?: string },
|
||||
models?: readonly { providerID: string; id: string; name: string }[],
|
||||
previous?: { providerID: string; id: string; variant?: string },
|
||||
) {
|
||||
if (previous?.providerID === model.providerID && previous.id === model.id)
|
||||
return `Switched variant to ${model.variant ?? "default"}`
|
||||
const display = models?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name
|
||||
if (display === undefined) return `Switched model to ${formatRef(model)}`
|
||||
// Variant-only switches publish the same model id; without the variant the
|
||||
// notice would look like a redundant model switch.
|
||||
const variant = model.variant && model.variant !== "default" ? ` (${model.variant})` : ""
|
||||
return `Switched model to ${display}${variant}`
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ async function setup() {
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
status: () => ({ type: "busy" }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -87,46 +88,34 @@ function durable(sessionID: string) {
|
||||
return { aggregateID: sessionID, seq: 0, version: 1 }
|
||||
}
|
||||
|
||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||
function executionStarted(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
type: "session.execution.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
data: { sessionID },
|
||||
}
|
||||
}
|
||||
|
||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||
function executionSucceeded(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.ended",
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
finish,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
data: { sessionID },
|
||||
}
|
||||
}
|
||||
|
||||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||
function executionFailed(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.failed",
|
||||
type: "session.execution.failed",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
}
|
||||
@@ -187,12 +176,12 @@ describe("internal notifications TUI plugin", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
test("notifies for terminal lifecycle events even when attached after execution started", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepEnded("event-1"))
|
||||
harness.emit(stepStarted("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
harness.emit(executionSucceeded("event-1"))
|
||||
harness.emit(executionStarted("event-2"))
|
||||
harness.emit(executionSucceeded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
@@ -201,6 +190,12 @@ describe("internal notifications TUI plugin", () => {
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -208,8 +203,8 @@ describe("internal notifications TUI plugin", () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1", "subagent") })
|
||||
harness.emit(stepStarted("event-2", "subagent"))
|
||||
harness.emit(stepEnded("event-3", "subagent"))
|
||||
harness.emit(executionStarted("event-2", "subagent"))
|
||||
harness.emit(executionSucceeded("event-3", "subagent"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
@@ -230,14 +225,14 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepStarted("event-1"))
|
||||
harness.emit(stepFailed("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
harness.emit(executionStarted("event-1"))
|
||||
harness.emit(executionFailed("event-2"))
|
||||
harness.emit(executionSucceeded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session error",
|
||||
message: "boom",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
@@ -247,20 +242,21 @@ describe("internal notifications TUI plugin", () => {
|
||||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepStarted("event-1", "abort"))
|
||||
harness.emit(executionStarted("event-1", "abort"))
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit(stepStarted("event-3", "timeout"))
|
||||
harness.emit(executionStarted("event-3", "timeout"))
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
harness.emit(executionFailed("event-5", "timeout"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@ import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { createSessionRows } from "../../../src/routes/session/rows"
|
||||
import { DataProvider, mergeActiveSessionStatus, useData } from "../../../src/context/data"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
@@ -28,6 +28,24 @@ function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
||||
test("active bootstrap preserves newer lifecycle status", () => {
|
||||
expect(
|
||||
mergeActiveSessionStatus(
|
||||
{ "snapshot-active": {} },
|
||||
{ "started-during-bootstrap": "running", "stopped-during-bootstrap": "idle" },
|
||||
new Map([
|
||||
["started-during-bootstrap", 2],
|
||||
["stopped-during-bootstrap", 3],
|
||||
]),
|
||||
1,
|
||||
),
|
||||
).toEqual({
|
||||
"snapshot-active": "running",
|
||||
"started-during-bootstrap": "running",
|
||||
"stopped-during-bootstrap": "idle",
|
||||
})
|
||||
})
|
||||
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
const events = createEventStream()
|
||||
const location = {
|
||||
@@ -110,20 +128,12 @@ test("refreshes resources into reactive getters", async () => {
|
||||
|
||||
test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
const events = createEventStream()
|
||||
const requests = { active: 0, event: 0, model: 0 }
|
||||
let resolveActive!: (response: Response) => void
|
||||
const requests = { event: 0, model: 0 }
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/event") {
|
||||
requests.event++
|
||||
return events.v2()
|
||||
}
|
||||
if (url.pathname === "/api/session/active") {
|
||||
requests.active++
|
||||
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } }, watermarks: {} })
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveActive = resolve
|
||||
})
|
||||
}
|
||||
if (url.pathname !== "/api/model") return
|
||||
requests.model++
|
||||
return json({
|
||||
@@ -166,7 +176,6 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
|
||||
try {
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
|
||||
await wait(() => data.session.status("session-stale") === "running")
|
||||
expect(data.connection.status()).toBe("connected")
|
||||
expect(data.connection.attempt()).toBe(0)
|
||||
|
||||
@@ -175,25 +184,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
expect(data.connection.attempt()).toBe(1)
|
||||
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||
|
||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started_after_reconnect",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-new"),
|
||||
data: {
|
||||
sessionID: "session-new",
|
||||
assistantMessageID: "message-new",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
resolveActive(json({ data: {}, watermarks: {} }))
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
expect(data.session.status("session-new")).toBe("running")
|
||||
expect(requests.event).toBe(2)
|
||||
expect(data.connection.status()).toBe("connected")
|
||||
expect(data.connection.attempt()).toBe(0)
|
||||
@@ -203,87 +194,6 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("completes exploration when a queued prompt is promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-promotion"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
|
||||
function Probe() {
|
||||
rows = createSessionRows(() => sessionID)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_tool_started",
|
||||
created: 2,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
callID: "call-read",
|
||||
name: "read",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_admitted",
|
||||
created: 3,
|
||||
type: "session.prompt.admitted",
|
||||
durable: durable(sessionID, 2),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-user",
|
||||
prompt: { text: "Continue" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.at(-1)?.type === "message")
|
||||
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_promoted",
|
||||
created: 4,
|
||||
type: "session.prompt.promoted",
|
||||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
|
||||
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("connectedOnce is false until first connect and persists across disconnect", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
@@ -353,9 +263,11 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
rows = createSessionRows(() => "session-retry")
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -375,6 +287,15 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
await wait(() => data.session.status("session-active") === "running")
|
||||
expect(data.session.status("session-idle")).toBe("idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-live"),
|
||||
data: { sessionID: "session-live" },
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
created: 0,
|
||||
@@ -387,8 +308,6 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_ended",
|
||||
created: 0,
|
||||
@@ -409,16 +328,23 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
expect(data.session.status("session-live")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_settled",
|
||||
id: "evt_execution_succeeded",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
outcome: "success",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("session-live", 1, 3),
|
||||
data: { sessionID: "session-live" },
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-failed"),
|
||||
data: { sessionID: "session-failed" },
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_step_started",
|
||||
created: 0,
|
||||
@@ -431,8 +357,6 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_failed",
|
||||
created: 0,
|
||||
@@ -441,26 +365,146 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-failed", "message-failed")
|
||||
return assistant?.type === "assistant" && assistant.finish === "error"
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.finish === "error" &&
|
||||
assistant.error?.type === "provider.content-filter"
|
||||
)
|
||||
})
|
||||
expect(data.session.status("session-failed")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_settled",
|
||||
id: "evt_failed_execution_failed",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
type: "session.execution.failed",
|
||||
durable: durable("session-failed", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
outcome: "failure",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-retry"),
|
||||
data: { sessionID: "session-retry" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_step_started",
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 2),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled",
|
||||
created: 0,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_next_step",
|
||||
created: 2_000,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 4),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry === undefined
|
||||
})
|
||||
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled_again",
|
||||
created: 2_000,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 5),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.transport", message: "Disconnected again" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 3
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_interrupted",
|
||||
created: 2_000,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("session-retry", 1, 6),
|
||||
data: { sessionID: "session-retry", reason: "shutdown" },
|
||||
})
|
||||
await wait(() => data.session.status("session-retry") === "idle")
|
||||
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
created: 0,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-live", 2),
|
||||
data: { sessionID: "session-live", reason: "auto" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_1",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "Live " },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_2",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "summary" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === "Live summary")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
created: 0,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-live", 3),
|
||||
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === undefined)
|
||||
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "Live summary",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -876,18 +920,7 @@ test("adds and dismisses question requests from live events", async () => {
|
||||
|
||||
test("settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message/msg_model_1")
|
||||
return json({
|
||||
data: {
|
||||
id: "msg_model_1",
|
||||
type: "model-switched",
|
||||
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
|
||||
model: { id: "model-1", providerID: "provider-1", variant: "high" },
|
||||
time: { created: 0 },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
const calls = createFetch(undefined, events)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
@@ -928,7 +961,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
durable: durable("session-1", 1),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
model: { id: "model-1", providerID: "provider-1", variant: "high" },
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
@@ -964,9 +997,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
executed: false,
|
||||
state: { call: true },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
@@ -979,7 +1012,8 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
executed: false,
|
||||
resultState: { result: true },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1005,21 +1039,14 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(tool.executed).toBe(false)
|
||||
expect(tool.providerState).toEqual({ call: true })
|
||||
expect(tool.providerResultState).toEqual({ result: true })
|
||||
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"assistant",
|
||||
])
|
||||
expect(sync.session.message.get("session-1", "msg_model_1")).toMatchObject({
|
||||
type: "model-switched",
|
||||
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
|
||||
model: { id: "model-1", providerID: "provider-1", variant: "high" },
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
const messages: SessionMessage[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", id: "text-1", text: "Looking" },
|
||||
{ type: "text", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||
{ type: "text", id: "text-2", text: "Done" },
|
||||
{ type: "text", text: "Done" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("assigns stable kind ordinals within an assistant message", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Second" },
|
||||
{ type: "reasoning", text: "Check" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
|
||||
{ type: "reasoning", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "reasoning", id: "reasoning-2", text: "" },
|
||||
{ type: "reasoning", text: "" },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
]),
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
]
|
||||
@@ -182,6 +198,17 @@ test("renders synthetic messages with descriptions", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
const message = assistant("assistant-retry", [])
|
||||
message.retry = {
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
}
|
||||
|
||||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
||||
@@ -34,16 +34,4 @@ describe("util.model", () => {
|
||||
"Switched model to removed/gone/high",
|
||||
)
|
||||
})
|
||||
|
||||
test("distinguishes variant-only switches from model switches", () => {
|
||||
const previous = { providerID: "openai", id: "gpt-5.5", variant: "medium" }
|
||||
|
||||
expect(switchLabel({ ...previous, variant: "high" }, undefined, previous)).toBe("Switched variant to high")
|
||||
expect(switchLabel({ providerID: "openai", id: "gpt-5.5" }, undefined, previous)).toBe(
|
||||
"Switched variant to default",
|
||||
)
|
||||
expect(switchLabel({ providerID: "anthropic", id: "sonnet", variant: "high" }, undefined, previous)).toBe(
|
||||
"Switched model to anthropic/sonnet/high",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -749,12 +749,12 @@ You can configure file watcher ignore patterns through the `watcher` option.
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"watcher": {
|
||||
"ignore": ["**/generated/**"]
|
||||
"ignore": ["node_modules/**", "dist/**", ".git/**"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Patterns follow glob syntax. Common dependency, VCS, build, and cache directories are ignored automatically at any depth.
|
||||
Patterns follow glob syntax. Use this to exclude noisy directories from file watching.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user