Compare commits

..

10 Commits

Author SHA1 Message Date
Dax Raad 8f7c6c33d9 refactor(plugin): return tool output directly 2026-07-04 17:09:51 +00:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
104 changed files with 1903 additions and 4069 deletions
-36
View File
@@ -1,36 +0,0 @@
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" })
})
})
},
}
+19
View File
@@ -104,6 +104,25 @@ 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.
+1
View File
@@ -151,6 +151,7 @@ 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.
+125 -560
View File
@@ -928,6 +928,7 @@ 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
@@ -1006,20 +1007,23 @@ export type SessionContextOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "text"; readonly id: string; readonly text: string }
| {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -1057,37 +1061,7 @@ 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: "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 error: { readonly type: "unknown"; readonly message: string }
readonly result?: JsonValue
}
readonly time: {
@@ -1099,7 +1073,7 @@ export type SessionContextOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly finish?: string
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -1107,56 +1081,7 @@ export type SessionContextOutput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: 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 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 error?: { readonly type: "unknown"; readonly message: string }
}
| {
readonly type: "compaction"
@@ -1287,71 +1212,6 @@ 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
@@ -1461,7 +1321,7 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly finish: string
readonly cost: number
readonly tokens: {
readonly input: number
@@ -1483,33 +1343,7 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: 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 error: { readonly type: "unknown"; readonly message: string }
}
}
| {
@@ -1519,7 +1353,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 data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
}
| {
readonly id: string
@@ -1528,7 +1362,12 @@ 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 text: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly text: string
}
}
| {
readonly id: string
@@ -1540,7 +1379,8 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly state?: { readonly [x: string]: unknown }
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
@@ -1553,8 +1393,9 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly state?: { readonly [x: string]: unknown }
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
@@ -1596,9 +1437,12 @@ export type SessionLogOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
@@ -1637,8 +1481,10 @@ export type SessionLogOutput =
>
readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
@@ -1652,77 +1498,32 @@ export type SessionLogOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: 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 error: { readonly type: "unknown"; readonly message: string }
readonly result?: unknown
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.retry.scheduled"
readonly type: "session.retried"
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 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 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 }
}
}
}
| {
@@ -1788,7 +1589,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 to: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
}
)
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
@@ -1821,6 +1622,7 @@ 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
@@ -1899,20 +1701,23 @@ export type SessionMessageOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "text"; readonly id: string; readonly text: string }
| {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -1950,37 +1755,7 @@ 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: "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 error: { readonly type: "unknown"; readonly message: string }
readonly result?: JsonValue
}
readonly time: {
@@ -1992,7 +1767,7 @@ export type SessionMessageOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly finish?: string
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -2000,56 +1775,7 @@ export type SessionMessageOutput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: 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 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 error?: { readonly type: "unknown"; readonly message: string }
}
| {
readonly type: "compaction"
@@ -2096,6 +1822,7 @@ 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
@@ -2174,20 +1901,23 @@ export type MessageListOutput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "text"; readonly id: string; readonly text: string }
| {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
@@ -2225,37 +1955,7 @@ 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: "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 error: { readonly type: "unknown"; readonly message: string }
readonly result?: JsonValue
}
readonly time: {
@@ -2267,7 +1967,7 @@ export type MessageListOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly finish?: string
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -2275,56 +1975,7 @@ export type MessageListOutput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: 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 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 error?: { readonly type: "unknown"; readonly message: string }
}
| {
readonly type: "compaction"
@@ -4837,63 +4488,14 @@ export type EventSubscribeOutput =
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 type: "session.execution.settled"
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 outcome: "success" | "failure" | "interrupted"
readonly error?: { readonly type: "unknown"; readonly message: 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
@@ -5003,7 +4605,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly finish: string
readonly cost: number
readonly tokens: {
readonly input: number
@@ -5025,29 +4627,7 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: 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 error: { readonly type: "unknown"; readonly message: string }
}
}
| {
@@ -5057,7 +4637,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 data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
}
| {
readonly id: string
@@ -5065,7 +4645,12 @@ 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 delta: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly delta: string
}
}
| {
readonly id: string
@@ -5074,7 +4659,12 @@ 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 text: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly text: string
}
}
| {
readonly id: string
@@ -5086,7 +4676,8 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly state?: { readonly [x: string]: unknown }
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
@@ -5095,7 +4686,12 @@ 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 delta: string }
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly delta: string
}
}
| {
readonly id: string
@@ -5107,8 +4703,9 @@ export type EventSubscribeOutput =
readonly data: {
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly state?: { readonly [x: string]: unknown }
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
@@ -5163,9 +4760,12 @@ export type EventSubscribeOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
@@ -5204,8 +4804,10 @@ export type EventSubscribeOutput =
>
readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
@@ -5219,69 +4821,32 @@ export type EventSubscribeOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: 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 error: { readonly type: "unknown"; readonly message: string }
readonly result?: unknown
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.retry.scheduled"
readonly type: "session.retried"
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 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 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 }
}
}
}
| {
@@ -5355,7 +4920,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 to: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
}
| {
readonly id: string
+4 -36
View File
@@ -2,8 +2,7 @@ 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, Stream } from "effect"
import { createRequire } from "node:module"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
@@ -36,9 +35,6 @@ 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) {
@@ -46,7 +42,6 @@ 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> }[] = []
@@ -110,7 +105,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(cacheBust(entrypoint)))
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
@@ -118,40 +113,13 @@ export const Plugin = define({
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
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 }),
)
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
}),
})
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
View File
@@ -44,7 +44,5 @@ 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[]
@@ -1,14 +0,0 @@
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
@@ -1,14 +0,0 @@
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
+1 -1
View File
@@ -45,7 +45,7 @@ const FILES = [
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
+7 -38
View File
@@ -59,14 +59,7 @@ export type AskResult = typeof AskResult.Type
export const Event = Permission.Event
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 RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
@@ -74,13 +67,7 @@ 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,
@@ -132,17 +119,9 @@ const layer = Layer.effect(
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
Effect.forEach(
pending.values(),
(item) =>
Deferred.fail(
item.deferred,
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
),
{
discard: true,
},
).pipe(
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
@@ -222,8 +201,6 @@ 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
@@ -253,12 +230,7 @@ const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message
? new CorrectedError({ feedback: input.message })
: new RejectedError({
permission: existing.request.action,
resources: [...existing.request.resources],
}),
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
@@ -268,10 +240,7 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(
item.deferred,
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
)
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
+3 -3
View File
@@ -540,7 +540,8 @@ 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,
{
@@ -563,8 +564,7 @@ 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,
+1 -7
View File
@@ -225,13 +225,7 @@ const make = (dependencies: Dependencies) => {
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
sessionID: input.sessionID,
text: event.text,
})
}
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
return Effect.void
}),
Effect.as(true),
-18
View File
@@ -1,7 +1,6 @@
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,
@@ -11,20 +10,3 @@ 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"
}
}
+19 -48
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Exit, Layer } from "effect"
import { Cause, DateTime, Effect, Exit, Layer } from "effect"
import { EventV2 } from "../../event"
import { LocationServiceMap } from "../../location-service-map"
import { makeGlobalNode } from "../../effect/app-node"
@@ -8,16 +8,6 @@ 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(
@@ -26,23 +16,7 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
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 })),
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
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}`))
@@ -55,31 +29,28 @@ const layer = Layer.effect(
),
)
}),
// 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,
})
}),
// 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,
),
})
return SessionExecution.Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
+52 -69
View File
@@ -1,5 +1,5 @@
import { castDraft, produce, type WritableDraft } from "immer"
import { DateTime, Effect } from "effect"
import { Effect } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -8,6 +8,7 @@ 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,
@@ -29,6 +30,15 @@ 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()
@@ -89,11 +99,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) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
@@ -101,17 +111,6 @@ 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) => {
@@ -126,25 +125,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.model.selected": (event) => {
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 },
}),
)
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 },
}),
)
})
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
"session.forked": () => Effect.void,
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.settled": () => Effect.void,
"session.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
@@ -205,26 +205,10 @@ 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
}),
)
@@ -260,24 +244,25 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = "error"
draft.error = castDraft(event.data.error)
draft.retry = undefined
draft.error = event.data.error
})
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
})
},
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
})
},
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
})
},
@@ -307,8 +292,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
match.provider = event.data.provider
match.time.ran = event.created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
@@ -334,8 +318,11 @@ 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.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
@@ -354,8 +341,11 @@ 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.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateError.make({
@@ -376,8 +366,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: event.data.reasoningID,
text: "",
state: event.data.state,
providerMetadata: event.data.providerMetadata,
time: { created: event.created },
}),
),
@@ -386,29 +377,21 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
})
},
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
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),
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
},
"session.retried": () => Effect.void,
"session.compaction.started": () => Effect.void,
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
+19 -7
View File
@@ -5,6 +5,7 @@ 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"
@@ -356,6 +357,17 @@ 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.
@@ -570,13 +582,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) =>
@@ -622,9 +634,6 @@ 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) =>
@@ -651,7 +660,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.RetryScheduled, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
@@ -678,11 +687,14 @@ const layer = Layer.effectDiscard(
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.id, event.data.messageID),
),
)
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`))
yield* db
.delete(SessionMessageTable)
.where(
+1 -1
View File
@@ -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,
to: session.revert.messageID,
messageID: session.revert.messageID,
})
})
+13 -28
View File
@@ -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, Reason = never> {
export interface Coordinator<Key, E> {
/** 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, Reason = never> {
/** 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, reason?: Reason) => Effect.Effect<void>
readonly interrupt: (key: Key) => 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,13 +23,11 @@ export interface Coordinator<Key, E, Reason = never> {
* 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, Reason> = {
type Execution<E> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
}
/**
@@ -43,21 +41,19 @@ type Execution<E, Reason> = {
* waiters get this exit
* ```
*/
export const make = <Key, E, Reason = never>(options: {
export const make = <Key, E>(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>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
readonly settled?: (key: Key, exit: Exit.Exit<void, E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const executions = new Map<Key, Execution<E, Reason>>()
const executions = new Map<Key, Execution<E>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
@@ -70,25 +66,15 @@ export const make = <Key, E, Reason = never>(options: {
)
const start = (key: Key, force: boolean) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
settling: false,
}
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: 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) =>
Effect.sync(() => {
execution.settling = true
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
Effect.asVoid,
@@ -99,7 +85,7 @@ export const make = <Key, E, Reason = never>(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, Reason>, exit: Exit.Exit<void, E>) => {
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
@@ -126,13 +112,12 @@ export const make = <Key, E, Reason = never>(options: {
start(key, false)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
const interrupt = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
if (execution?.owner === undefined) return Effect.void
execution.stopping = true
execution.pendingWake = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
})
+2 -8
View File
@@ -3,19 +3,13 @@ export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
import type { MessageDecodeError } 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
| StepFailedError
| UserInterruptedError
| SystemContext.InitializationBlocked
| ToolOutputStore.Error
LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+33 -103
View File
@@ -10,8 +10,7 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@@ -32,7 +31,6 @@ 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"
@@ -45,9 +43,6 @@ 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.
@@ -58,10 +53,10 @@ import { SessionRunnerRetry } from "./retry"
* - 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.
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Honor optional agent step limits.
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
* - [ ] Bound provider retries and repeated identical tool calls.
*
* - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
@@ -70,7 +65,7 @@ import { SessionRunnerRetry } from "./retry"
* - [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)` call per attempt.
* - [x] Stream exactly one `llm.stream(request)` physical 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.
@@ -91,7 +86,7 @@ import { SessionRunnerRetry } from "./retry"
* - [ ] 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 attempt. Keep tool execution and continuation here.
* Use `llm.stream(request)` for each physical 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
@@ -141,14 +136,17 @@ const layer = Layer.effect(
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "tool.stale", message: "Tool execution interrupted", name: tool.name },
executed: tool.executed === true,
error: { type: "unknown", message: "Tool execution interrupted" },
provider: {
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
})
}
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error | UserInterruptedError>) =>
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
@@ -173,7 +171,6 @@ 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)
@@ -187,7 +184,7 @@ const layer = Layer.effect(
loadSystemContext(agent, session.id),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
let currentStep = step
if (promotion) {
@@ -231,23 +228,21 @@ 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> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
serialized(publisher.publish(event, outputPaths))
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.hasRetryEvidence()) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
@@ -255,12 +250,7 @@ const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
return
}
needsContinuation = true
@@ -283,15 +273,6 @@ 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,
),
),
),
),
@@ -338,7 +319,7 @@ const layer = Layer.effect(
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasRetryEvidence() &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
@@ -350,26 +331,8 @@ const layer = Layer.effect(
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
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))
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
@@ -379,30 +342,27 @@ 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 || permissionRejected || streamInterrupted || toolsInterrupted) {
if (questionDismissed || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed || permissionRejected) return yield* new UserInterruptedError()
if (questionDismissed) return yield* Effect.interrupt
}
// 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 && !permissionRejected ? settled.cause : undefined
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const error = toSessionError(failure)
yield* serialized(publisher.failUnsettledTools(error))
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
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 stepSettlement = publisher.stepSettlement()
@@ -411,21 +371,13 @@ 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({ type: "aborted", message: "Tool execution interrupted" }))
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
yield* serialized(publisher.failUnsettledTools("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,
@@ -446,31 +398,8 @@ 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* 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)))
}),
)
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
@@ -479,7 +408,8 @@ const layer = Layer.effect(
}
})
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
// drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
@@ -1,19 +1,16 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { Effect } from "effect"
import { DateTime, 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)
@@ -44,10 +41,10 @@ const message = (value: unknown) => {
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: SessionError.Error }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
if (result.type === "error") return { error: { type: "unknown", 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 }
@@ -64,25 +61,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
}
>()
let assistantMessageID = input.assistantMessageID
let stepStarted = false
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let retryEvidence = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
}
| undefined
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
const startAssistant = Effect.fnUntraced(function* () {
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
assistantMessageID ??= SessionMessage.ID.create()
stepStarted = true
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create()
assistantActive = true
yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
@@ -94,18 +86,15 @@ 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, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
) => {
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
})
@@ -116,10 +105,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
current.push(value)
return Effect.void
})
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.join(""), state)
yield* ended(id, current.join(""), providerMetadata)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
@@ -128,30 +117,26 @@ 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(),
text: value,
})
}),
true,
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 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 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 toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
@@ -206,21 +191,21 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments()
})
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error) {
const failAssistant = Effect.fnUntraced(function* (message: string) {
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,
error: { type: "unknown", message },
})
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
error: SessionError.Error,
message: string,
hostedOnly = false,
) {
for (const [callID, tool] of tools) {
@@ -230,8 +215,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
executed: tool.providerExecuted,
error: { type: "unknown", message },
provider: {
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
})
}
})
@@ -244,18 +232,16 @@ 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":
@@ -263,6 +249,7 @@ 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
@@ -270,12 +257,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(),
state: providerState(event.providerMetadata),
reasoningID: event.id,
providerMetadata: event.providerMetadata,
})
return
case "reasoning-delta":
@@ -283,14 +270,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, providerState(event.providerMetadata))
yield* reasoning.end(event.id, event.providerMetadata)
return
case "tool-input-start":
retryEvidence = true
yield* startToolInput(event)
return
case "tool-input-delta": {
@@ -312,7 +299,6 @@ 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)
@@ -321,19 +307,21 @@ 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
const state = providerState(event.providerMetadata)
tool.providerMetadata = event.providerMetadata
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
input: record(event.input),
executed: tool.providerExecuted,
state,
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
})
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)
@@ -343,9 +331,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
const result = settledOutput(event.output, event.result)
const provider = {
executed: event.providerExecuted === true || tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
@@ -353,8 +343,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
error: result.error,
result: event.result,
executed,
resultState,
provider,
})
return
}
@@ -364,14 +353,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
...result,
outputPaths,
...(executed ? { result: event.result } : {}),
executed,
resultState,
...(provider.executed ? { result: event.result } : {}),
provider,
})
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)
@@ -382,30 +369,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
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),
error: { type: "unknown", message: event.message },
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: 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({ type: "provider.unknown", message: event.message })
yield* failAssistant(event.message)
return
}
})
@@ -415,9 +397,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,
-67
View File
@@ -1,67 +0,0 @@
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,11 +21,6 @@ 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)
@@ -36,7 +31,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
id: tool.id,
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.executed,
providerExecuted: tool.provider?.executed,
providerMetadata,
})
@@ -45,14 +40,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.executed === true && tool.state.result !== undefined
tool.provider?.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.executed,
providerExecuted: tool.provider?.executed,
providerMetadata,
})
}
@@ -61,11 +56,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
id: tool.id,
name: tool.name,
result:
tool.executed === true && tool.state.result !== undefined
tool.provider?.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.executed,
providerExecuted: tool.provider?.executed,
providerMetadata,
})
}
@@ -83,22 +78,17 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? providerMetadata(model.provider, item.state) : undefined,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(
item,
reuseProviderMetadata ? providerMetadata(model.provider, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
: undefined,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
)
return result ? [call, result] : [call]
})
@@ -108,14 +98,9 @@ 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.executed !== true)
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) =>
toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.provider, item.providerResultState ?? item.providerState)
: undefined,
),
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
)
.filter((message) => message !== undefined)
.map(Message.tool)
@@ -1,65 +0,0 @@
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) }
}
+3 -3
View File
@@ -4,7 +4,7 @@ This folder owns Core's one local tool representation, process and Location regi
## Representations
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type.
- `tool.ts` re-exports the opaque canonical `Tool.make({ description, input, output, execute })` value. Shipped built-ins and plugin tools use the same type.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
@@ -12,7 +12,7 @@ Do not add a second executable entry type, registry-owned executor, authorizatio
## Construction
Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
Tool schemas use `input` and `output` terminology. Executors return `{ structured, content }` directly. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
@@ -46,7 +46,7 @@ Definition filtering is catalog visibility, not execution authorization. A call
## Output
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
Built-ins return complete structured output and model content together. `ToolRegistry.Materialization.settle` validates and encodes the structured value, then applies generic model-output bounding and owns managed retention paths.
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
+11 -6
View File
@@ -70,15 +70,14 @@ export const Plugin = {
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, error?: unknown) => {
const fail = (path: string) => {
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, error })
return new ToolFailure({ message: prefix })
}
return Effect.gen(function* () {
const source = {
@@ -150,7 +149,7 @@ export const Plugin = {
before,
after: update.content,
})
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
@@ -180,11 +179,17 @@ export const Plugin = {
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError((error) => fail(change.path, error))),
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
}).pipe(
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))),
Effect.map((structured) => ({
structured,
content: [{ type: "text", text: toModelOutput(structured) }],
})),
)
},
}),
"edit",
+7 -6
View File
@@ -101,9 +101,6 @@ export const Plugin = {
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
@@ -111,9 +108,8 @@ 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}`, error }),
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
),
)
@@ -205,7 +201,12 @@ export const Plugin = {
],
replacements,
} satisfies Output
})
}).pipe(
Effect.map((structured) => ({
structured,
content: [{ type: "text", text: toModelOutput(structured, input.oldString, input.newString) }],
})),
)
},
}),
"edit",
+27 -10
View File
@@ -5,6 +5,7 @@ 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"
@@ -36,6 +37,7 @@ 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
@@ -47,14 +49,6 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -71,6 +65,13 @@ 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,
@@ -88,9 +89,25 @@ export const Plugin = {
),
)
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
),
Effect.map((structured) => ({
structured,
content: [
{
type: "text",
text: toModelOutput(
structured.map((entry) => ({
...entry,
path: path.resolve(location.directory, entry.path),
})),
),
},
],
})),
),
}),
})
+26 -13
View File
@@ -63,17 +63,6 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -91,7 +80,13 @@ 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.catch(() => Effect.succeed(undefined)))
const info = yield* fs
.stat(target)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
return yield* ripgrep
.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
@@ -122,7 +117,25 @@ export const Plugin = {
),
)
}).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error })),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
),
Effect.map((structured) => ({
structured,
content: [
{
type: "text",
text: toModelOutput(
structured.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
})),
),
}),
})
+5 -5
View File
@@ -54,9 +54,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission
.assert({
@@ -67,7 +64,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.andThen(
question
.ask({
@@ -77,7 +74,10 @@ export const Plugin = {
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
Effect.map((answers) => ({
structured: { answers },
content: [{ type: "text", text: toModelOutput(input.questions, answers) }],
})),
),
}),
})
+18 -9
View File
@@ -48,14 +48,6 @@ export const Plugin = {
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
]
},
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
@@ -132,8 +124,25 @@ export const Plugin = {
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
return new ToolFailure({ message })
}),
Effect.map((structured) => ({
structured,
content:
"encoding" in structured &&
structured.encoding === "base64" &&
SUPPORTED_IMAGE_MIMES.has(structured.mime)
? [
{ type: "text" as const, text: "Image read successfully" },
{
type: "file" as const,
data: structured.content,
mime: structured.mime,
name: input.path,
},
]
: [],
})),
)
},
}),
+10 -34
View File
@@ -12,8 +12,6 @@ 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
@@ -42,7 +40,6 @@ 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") {}
@@ -63,15 +60,9 @@ 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}` },
error: { type: "tool.stale" as const, message: `Stale tool call: ${input.call.name}`, name: input.call.name },
}
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
@@ -82,33 +73,22 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(
registration.tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
},
).pipe(
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
let settlement: Settlement
if ("result" in pending) {
settlement = pending
} else {
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: pending.output,
})
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
@@ -135,7 +115,6 @@ 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 } : {}),
}
})
@@ -178,10 +157,7 @@ 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}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: input.call.name },
})
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
},
}
}),
+21 -28
View File
@@ -18,7 +18,9 @@ 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 has not completed; it is now running in the background."
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."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@@ -43,20 +45,17 @@ const StructuredOutput = Schema.Struct({
timeout: Schema.Boolean.pipe(Schema.optional),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
type Result = typeof StructuredOutput.Type & {
readonly output: string
readonly status?: "completed" | "running"
readonly warnings?: ReadonlyArray<string>
}
type Output = typeof Output.Type
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return undefined
const modelOutput = (output: Result): string | 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}.`
}
@@ -141,20 +140,7 @@ export const Plugin = {
[name]: Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => {
const parts: Content[] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) parts.push({ type: "text", text: model })
return parts
},
output: StructuredOutput,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -266,9 +252,16 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}),
}
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
Effect.map((result) => {
const content: Content[] = [{ type: "text", text: result.output }]
const model = modelOutput(result)
if (model) content.push({ type: "text", text: model })
return {
structured: result,
content,
}
}),
),
}),
})
+5 -2
View File
@@ -64,7 +64,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
@@ -87,11 +86,15 @@ export const Plugin = {
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
const structured = {
name: skill.name,
directory,
output: toModelOutput(skill, files),
}
return {
structured,
content: [{ type: "text" as const, text: structured.output }],
}
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}),
}),
+14 -10
View File
@@ -92,21 +92,23 @@ export const Plugin = {
)
})
const output = (structured: typeof Output.Type) => ({
structured,
content: [{ type: "text" as const, text: structured.output }],
})
yield* ctx.tool
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
.get(context.sessionID)
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
)
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
@@ -125,9 +127,7 @@ export const Plugin = {
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
)
const background = input.background === true
@@ -150,7 +150,7 @@ export const Plugin = {
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
@@ -162,12 +162,16 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
return output({
sessionID: child.id,
status: "completed",
output: result?.info.output ?? NO_TEXT,
})
}),
}),
})
+6 -3
View File
@@ -33,7 +33,6 @@ export const Plugin = {
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -45,8 +44,12 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
const structured = { todos: input.todos }
return {
structured,
content: [{ type: "text" as const, text: toModelOutput(structured) }],
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}),
})
.pipe(Effect.orDie)
+7 -2
View File
@@ -124,7 +124,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
@@ -170,7 +169,13 @@ export const Plugin = {
format: input.format,
output,
}
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` })),
Effect.map((structured) => ({
structured,
content: [{ type: "text", text: structured.output }],
})),
),
}),
})
.pipe(Effect.orDie)
+5 -4
View File
@@ -200,7 +200,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
@@ -244,9 +243,11 @@ export const Plugin = {
text: text ?? NO_RESULTS,
}
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
Effect.map((structured) => ({
structured,
content: [{ type: "text", text: structured.text }],
})),
)
},
}),
+7 -2
View File
@@ -57,7 +57,6 @@ export const Plugin = {
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -83,7 +82,13 @@ export const Plugin = {
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` })),
Effect.map((structured) => ({
structured,
content: [{ type: "text", text: toModelOutput(structured) }],
})),
),
}),
"edit",
),
-73
View File
@@ -1,19 +1,15 @@
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"
@@ -244,49 +240,6 @@ 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) {
@@ -297,29 +250,3 @@ 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"
})
})
},
}`
}
+3 -7
View File
@@ -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 every config-backed domain", () =>
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
@@ -69,8 +69,7 @@ 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* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
)
}),
)
@@ -81,10 +80,7 @@ describe("config plugin reloads", () => {
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
@@ -15,7 +15,6 @@ 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"
@@ -39,29 +38,6 @@ 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,5 +1,7 @@
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)
@@ -8,3 +10,30 @@ 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)
})
+25 -5
View File
@@ -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, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, 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,12 +149,14 @@ describeWatcher("LocationWatcher", () => {
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* fs.writeFileString(sibling, "sibling")
yield* fs.writeFileString(target, "target")
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)))
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
@@ -197,6 +199,24 @@ 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
+3 -2
View File
@@ -108,7 +108,7 @@ describe("PluginV2", () => {
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
}),
})
.pipe(Effect.orDie),
@@ -146,7 +146,8 @@ describe("PluginV2", () => {
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ structured: { text }, content: [] })),
}),
})
.pipe(Effect.orDie)
@@ -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, Fiber, Layer, Stream } from "effect"
import { DateTime, Effect, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -73,7 +73,6 @@ 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 = {
@@ -109,12 +108,7 @@ 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.")
+3 -3
View File
@@ -562,9 +562,9 @@ describe("SessionV2.create", () => {
yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.model.selected", data: { 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 })
}),
)
-93
View File
@@ -1,93 +0,0 @@
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])
})
})
@@ -1,36 +0,0 @@
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" })
})
})
+6 -69
View File
@@ -34,6 +34,7 @@ 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 = (
@@ -95,7 +96,7 @@ describe("SessionProjector", () => {
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
to: boundary,
messageID: boundary,
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@@ -238,6 +239,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
@@ -337,6 +339,7 @@ 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 },
@@ -429,73 +432,6 @@ 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
@@ -589,6 +525,7 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
textID: "text-stale",
})
const rows = yield* db
@@ -607,7 +544,7 @@ describe("SessionProjector", () => {
type: "assistant",
agent: "build",
model,
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
SessionMessage.Assistant.make({
@@ -104,10 +104,8 @@ 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)
@@ -117,25 +115,6 @@ 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())
}),
),
)
@@ -230,41 +209,8 @@ describe("SessionRunCoordinator", () => {
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
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])
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
}),
),
)
@@ -275,28 +221,25 @@ describe("SessionRunCoordinator", () => {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
const coordinator = yield* SessionRunCoordinator.make({
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", "user")
yield* coordinator.interrupt("session")
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"])
}),
),
)
@@ -309,7 +252,6 @@ 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(
@@ -324,7 +266,6 @@ describe("SessionRunCoordinator", () => {
: Deferred.succeed(secondStarted, undefined),
),
),
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
})
yield* coordinator.wake("session")
@@ -337,7 +278,6 @@ describe("SessionRunCoordinator", () => {
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
expect(starts).toBe(2)
}),
),
)
@@ -459,7 +399,6 @@ 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: () =>
@@ -471,7 +410,6 @@ 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)),
@@ -486,7 +424,6 @@ 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,14 +28,17 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages(
[
assistant("empty", []),
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("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("reasoning", [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning",
text: "",
state: { signature: "sig_1" },
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
]),
],
@@ -155,11 +158,12 @@ Recent work
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-1",
text: "Think",
state: { signature: "sig_1" },
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -204,9 +208,11 @@ Recent work
type: "tool",
id: "hosted",
name: "web_search",
executed: true,
providerState: { continuation: "hosted-call" },
providerResultState: { continuation: "hosted-result" },
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@@ -219,8 +225,7 @@ Recent work
type: "tool",
id: "hosted-failed",
name: "write",
executed: true,
providerState: { continuation: "failed" },
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
@@ -240,7 +245,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: { provider: { signature: "sig_1" } } },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { 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" } },
{
@@ -255,14 +260,14 @@ Recent work
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { provider: { continuation: "hosted-call" } },
providerMetadata: { fake: { continuation: "hosted-call" } },
},
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { provider: { continuation: "hosted-result" } },
providerMetadata: { fake: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
@@ -271,14 +276,14 @@ Recent work
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { provider: { continuation: "failed" } },
providerMetadata: { fake: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { provider: { continuation: "failed" } },
providerMetadata: { fake: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
@@ -312,8 +317,9 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
],
time: { created, completed: created },
@@ -326,7 +332,7 @@ Recent work
{
type: "reasoning",
text: "Think",
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
@@ -342,16 +348,19 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed",
text: "Partial thought",
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed",
name: "web_search",
executed: true,
providerState: { itemId: "call_failed" },
providerResultState: { itemId: "result_failed" },
provider: {
executed: true,
metadata: { openai: { itemId: "call_failed" } },
resultMetadata: { openai: { itemId: "result_failed" } },
},
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
@@ -411,16 +420,19 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
state: { signature: "sig_old" },
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-old-model",
name: "web_search",
executed: true,
providerState: { itemId: "hosted-old-model" },
providerResultState: { itemId: "hosted-old-model" },
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@@ -434,9 +446,11 @@ Recent work
type: "tool",
id: "local-old-model",
name: "read",
executed: false,
providerState: { call: "old" },
providerResultState: { result: "old" },
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
@@ -47,7 +47,6 @@ const capture = () => {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
provider: "openai",
}),
}
}
@@ -91,7 +90,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
})
})
test("provider-executed success retains its raw provider result", async () => {
test("provider-executed success retains its compatibility 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 })))
@@ -99,19 +98,6 @@ test("provider-executed success retains its raw provider 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))
@@ -128,7 +114,7 @@ test("binary failure emits no success event", async () => {
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
})
test("success event data can carry a provider-executed result", () => {
test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
@@ -136,7 +122,7 @@ test("success event data can carry a provider-executed result", () => {
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" }] },
executed: true,
provider: { executed: false },
})
expect(decoded.result).toMatchObject({ type: "content" })
})
@@ -149,37 +135,3 @@ 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" },
})
})
@@ -46,8 +46,7 @@ const make = (permission?: string) => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }) => Effect.succeed({ structured: { text }, content: [{ type: "text" as const, text }] }),
})
return permission ? Tool.withPermission(tool, permission) : tool
}
@@ -244,7 +243,8 @@ describe("ToolRegistry", () => {
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
execute: (_, context) =>
Effect.sync(() => contexts.push(context)).pipe(Effect.as({ structured: { ok: true }, content: [] })),
}),
})
yield* executeTool(service, {
@@ -276,7 +276,7 @@ describe("ToolRegistry", () => {
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
it.effect("validates structured output while preserving executor-provided model content", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
@@ -291,18 +291,23 @@ describe("ToolRegistry", () => {
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
execute: ({ value }) =>
Effect.sync(() => executed.push(value)).pipe(
Effect.as({ structured: { value }, content: [{ type: "text" as const, text: value }] }),
),
}),
})
expect(
yield* executeTool(service, {
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
).toEqual({
result: { type: "text", value: "yes" },
output: { structured: { value: true }, content: [{ type: "text", text: "yes" }] },
})
expect(executed).toEqual(["yes"])
expect(
yield* executeTool(service, {
@@ -329,7 +334,7 @@ describe("ToolRegistry", () => {
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
execute: () => Effect.succeed({ structured: { value: "invalid" }, content: [] }),
}),
})
expect(
@@ -411,8 +416,10 @@ describe("ToolRegistry", () => {
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ structured: { text }, content: [{ type: "text" as const, text }] }),
),
}),
})
.pipe(Scope.provide(scope))
+60 -340
View File
@@ -4,10 +4,8 @@ import {
LLMError,
LLMEvent,
Model,
ToolFailure,
TransportReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
type LLMRequest,
} from "@opencode-ai/llm"
@@ -28,7 +26,6 @@ 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"
@@ -64,8 +61,7 @@ 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, Option, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -139,7 +135,6 @@ const echo = Layer.effectDiscard(
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
@@ -150,7 +145,7 @@ const echo = Layer.effectDiscard(
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
return { structured: { text }, content: [{ type: "text" as const, text }] }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
@@ -165,7 +160,7 @@ const echo = Layer.effectDiscard(
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Any,
execute: () => Effect.succeed({ big: 1n }),
execute: () => Effect.succeed({ structured: { big: 1n }, content: [] }),
}),
}),
),
@@ -372,20 +367,6 @@ 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
@@ -473,7 +454,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.textStart({ id }),
...chunks.map((text) => LLMEvent.textDelta({ id, text })),
]
const expectedContent = { type: "text", text }
const expectedContent = { type: "text", id, text }
return {
delta: SessionEvent.Text.Delta,
partialEvents,
@@ -493,7 +474,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.reasoningStart({ id }),
...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
]
const expectedContent = { type: "reasoning", text }
const expectedContent = { type: "reasoning", id, text }
return {
delta: SessionEvent.Reasoning.Delta,
partialEvents,
@@ -528,7 +509,6 @@ 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},`)
@@ -561,7 +541,6 @@ 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"])
@@ -575,11 +554,10 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
{
type: "assistant",
finish: "error",
error: { type: "provider.transport", message: "Provider unavailable" },
error: { type: "unknown", message: "Provider unavailable" },
content: [fixture.expectedContent],
},
])
expect(requests).toHaveLength(1)
})
const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
@@ -604,7 +582,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
{
type: "assistant",
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [
kind === "tool input"
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
@@ -629,7 +607,7 @@ describe("SessionRunnerLLM", () => {
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
return { answer: query.toUpperCase() }
return { structured: { answer: query.toUpperCase() }, content: [] }
}),
}),
})
@@ -1390,7 +1368,7 @@ describe("SessionRunnerLLM", () => {
overflow(),
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -1436,7 +1414,7 @@ describe("SessionRunnerLLM", () => {
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
@@ -1573,7 +1551,7 @@ describe("SessionRunnerLLM", () => {
finish: "tool-calls",
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
content: [
{ type: "reasoning", text: "Think" },
{ type: "reasoning", id: "reasoning-1", text: "Think" },
{
type: "tool",
id: "call-error",
@@ -1581,16 +1559,14 @@ describe("SessionRunnerLLM", () => {
state: {
status: "error",
input: { path: "README.md" },
error: { type: "tool.execution", message: "Denied" },
error: { type: "unknown", message: "Denied" },
},
},
{
type: "tool",
id: "call-provider",
name: "web_search",
executed: true,
providerState: { source: "provider" },
providerResultState: { source: "provider" },
provider: { executed: true, metadata: { fake: { source: "provider" } } },
state: {
status: "completed",
input: { query: "hello" },
@@ -1660,7 +1636,7 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
])
}),
)
@@ -1719,24 +1695,15 @@ 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: { fake: { signature: "sig_1" }, anthropic: { ignored: true } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-anthropic", providerMetadata: { anthropic: { signature: "sig_1" } } }),
LLMEvent.reasoningStart({
id: "reasoning-openai",
providerMetadata: {
fake: { itemId: "rs_1", reasoningEncryptedContent: null },
openai: { ignored: true },
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
}),
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
LLMEvent.reasoningEnd({
id: "reasoning-openai",
providerMetadata: {
fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
openai: { ignored: true },
},
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -1749,11 +1716,11 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
content: [
{ type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } },
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
{
type: "reasoning",
text: "Encrypted thought",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
],
},
@@ -1764,11 +1731,11 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
{ type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } },
{ type: "reasoning", text: "Signed thought", providerMetadata: { anthropic: { signature: "sig_1" } } },
{
type: "reasoning",
text: "Encrypted thought",
providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
@@ -1788,14 +1755,14 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } },
providerMetadata: { openai: { itemId: "hosted-search" } },
}),
LLMEvent.toolResult({
id: "hosted-search",
name: "web_search",
result: { type: "json", value: [{ title: "Effect" }] },
providerExecuted: true,
providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -1815,7 +1782,7 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { itemId: "hosted-search" } },
providerMetadata: { openai: { itemId: "hosted-search" } },
},
{
type: "tool-result",
@@ -1823,7 +1790,7 @@ describe("SessionRunnerLLM", () => {
name: "web_search",
result: { type: "json", value: [{ title: "Effect" }] },
providerExecuted: true,
providerMetadata: { fake: { blockType: "web_search_tool_result" } },
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
},
])
}),
@@ -2012,7 +1979,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", text: "Once" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-once", text: "Once" }] },
])
}),
)
@@ -2384,7 +2351,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
responses = undefined
response = []
streamFailure = invalidRequest()
streamFailure = providerUnavailable()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
@@ -2434,8 +2401,9 @@ describe("SessionRunnerLLM", () => {
sessionID,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
input: { text: "stale" },
executed: false,
provider: { executed: false },
})
requests.length = 0
response = []
@@ -2451,10 +2419,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-interrupted",
state: {
status: "error",
error: { type: "tool.stale", message: "Tool execution interrupted", name: "echo" },
},
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
},
],
},
@@ -2496,9 +2461,9 @@ describe("SessionRunnerLLM", () => {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
tool: "web_search",
input: { query: "stale" },
executed: true,
state: { itemId: "call-hosted-interrupted" },
provider: { executed: true, metadata: { openai: { itemId: "call-hosted-interrupted" } } },
})
requests.length = 0
response = []
@@ -2511,7 +2476,7 @@ describe("SessionRunnerLLM", () => {
type: "tool-call",
id: "call-hosted-interrupted",
providerExecuted: true,
providerMetadata: { fake: { itemId: "call-hosted-interrupted" } },
providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
},
{ type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
])
@@ -2697,7 +2662,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
responses = undefined
response = []
streamFailure = invalidRequest()
streamFailure = providerUnavailable()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
@@ -2760,7 +2725,7 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
{ type: "assistant", finish: "stop", content: [{ type: "text", id: "text-after-error", text: "Recovered" }] },
])
}),
)
@@ -2803,7 +2768,7 @@ describe("SessionRunnerLLM", () => {
id: "call-defect",
state: {
status: "error",
error: { type: "unknown", message: "unexpected tool defect" },
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
},
},
],
@@ -2846,80 +2811,13 @@ describe("SessionRunnerLLM", () => {
status: "error",
error: {
type: "unknown",
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"],
message: expect.stringContaining("Tool execution failed: Failed to encode tool output"),
},
},
},
],
},
])
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
}),
)
@@ -2935,7 +2833,9 @@ describe("SessionRunnerLLM", () => {
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
questions
.ask({ sessionID: context.sessionID, questions: [] })
.pipe(Effect.as({ structured: {}, content: [] }), Effect.orDie),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
@@ -2961,8 +2861,7 @@ describe("SessionRunnerLLM", () => {
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure")
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBeInstanceOf(UserInterruptedError)
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Ask then stop" },
@@ -2972,7 +2871,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-question",
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
},
],
},
@@ -3044,7 +2943,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-before-interrupt",
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
},
],
},
@@ -3085,7 +2984,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
yield* session.interrupt(sessionID)
@@ -3120,12 +3019,12 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "aborted", message: "Step interrupted" },
error: { type: "unknown", message: "Step interrupted" },
content: [
{
type: "tool",
id: "call-await-interrupt",
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
},
],
},
@@ -3248,12 +3147,12 @@ describe("SessionRunnerLLM", () => {
streamStarted = undefined
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail durably" },
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
])
}),
)
@@ -3267,43 +3166,16 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail before step" },
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
{ type: "assistant", finish: "error", error: { type: "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
@@ -3318,7 +3190,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3338,146 +3210,18 @@ 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 = invalidRequest()
const failure = providerUnavailable()
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: "provider.invalid-request", message: "Invalid request" } },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider unavailable" } },
])
}),
)
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
@@ -3496,7 +3240,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.providerError({ message: "Provider unavailable" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(executions.slice(executionCount)).toEqual(["settled"])
@@ -3521,7 +3265,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.providerError({ message: "Provider unavailable" }),
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3568,7 +3312,6 @@ 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([
@@ -3584,21 +3327,20 @@ 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: "provider.transport", message: "Provider unavailable" },
error: { type: "unknown", message: "Provider unavailable" },
content: [{ type: "tool", id: "call-hosted-raw-failure", state: { status: "error" } }],
},
])
}),
)
it.effect("rejects a second text start before the open fragment ends", () =>
it.effect("keeps interleaved assistant text blocks separate", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -3611,31 +3353,9 @@ 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.textEnd({ id: "text-1" }),
LLMEvent.textStart({ id: "text-2" }),
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
LLMEvent.textEnd({ id: "text-1" }),
LLMEvent.textEnd({ id: "text-2" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
@@ -3648,8 +3368,8 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
content: [
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
{ type: "text", id: "text-1", text: "First" },
{ type: "text", id: "text-2", text: "Second" },
],
},
])
@@ -76,8 +76,9 @@ describe("Tool.Progress", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
executed: false,
provider: { executed: false },
})
})
@@ -103,7 +104,7 @@ describe("Tool.Progress", () => {
callID: "call-success",
structured: { phase: "done" },
content: content("complete"),
executed: false,
provider: { executed: false },
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
@@ -122,7 +123,7 @@ describe("Tool.Progress", () => {
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
executed: false,
provider: { executed: false },
})
expect((yield* readAssistant).content[1]).toMatchObject({
state: {
+2 -3
View File
@@ -107,7 +107,6 @@ 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],
@@ -148,7 +147,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Delivery, SessionInput.Delivery],
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User],
@@ -198,7 +197,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", text: "hi" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
+1 -9
View File
@@ -47,15 +47,7 @@ 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: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
+1 -9
View File
@@ -40,15 +40,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
+2 -20
View File
@@ -23,17 +23,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
@@ -82,15 +72,7 @@ describe("QuestionTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({
result: { type: "error", value: "Permission denied: question" },
error: {
type: "permission.rejected",
message: "Permission denied: question",
permission: "question",
resources: ["*"],
},
})
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
expect(capturedInput()).toBeUndefined()
deny = false
}),
+1 -13
View File
@@ -81,19 +81,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(
Effect.andThen(
allow
? Effect.void
: Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
),
),
),
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
+87
View File
@@ -0,0 +1,87 @@
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]()),
),
)
}
})
+10 -11
View File
@@ -47,15 +47,7 @@ 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: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
@@ -83,6 +75,7 @@ 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,
@@ -92,10 +85,12 @@ 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, {
@@ -485,9 +480,13 @@ 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]).toMatchObject({
expect(settled.output?.content[0]).toEqual({
type: "text",
text: expect.stringContaining("running in the background"),
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
+1 -11
View File
@@ -55,17 +55,7 @@ describe("SkillTool", () => {
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+3
View File
@@ -49,6 +49,7 @@ const executionNode = makeGlobalNode({
}
completed.add(sessionID)
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_subagent_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
@@ -58,10 +59,12 @@ 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, {
+1 -11
View File
@@ -33,17 +33,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
+1 -9
View File
@@ -38,15 +38,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction
? Effect.fail(
new PermissionV2.DeniedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
@@ -619,11 +619,6 @@ 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[] = []
@@ -815,8 +810,6 @@ 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]
@@ -927,7 +920,6 @@ 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" ||
+2 -2
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
import { 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 = LLM.FinishReason
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -764,35 +764,6 @@ 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.interrupted" &&
event.data.reason === "user" &&
event.type === "session.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
) {
return
@@ -190,12 +190,11 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.text.started") {
starts.set("text", { id: partID(event.id), timestamp: time })
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.text.ended") {
const started = starts.get("text")
starts.delete("text")
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
sessionID: input.sessionID,
@@ -209,19 +208,18 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (event.type === "session.reasoning.started") {
starts.set("reasoning", { id: partID(event.id), timestamp: time })
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning")
starts.delete("reasoning")
const started = starts.get(event.data.reasoningID)
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.state,
metadata: event.data.providerMetadata,
time: { start: started?.timestamp ?? time, end: time },
}
if (emit("reasoning", time, { part })) continue
@@ -259,10 +257,10 @@ export async function runNonInteractivePrompt(input: Input) {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
tool: current?.tool ?? "tool",
tool: event.data.tool,
input: event.data.input,
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
provider: event.data.provider,
})
continue
}
@@ -289,7 +287,7 @@ export async function runNonInteractivePrompt(input: Input) {
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
@@ -316,7 +314,7 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
providerResult: event.data.provider,
rawInput: current.raw,
},
time: { start: current.timestamp, end: time },
@@ -351,25 +349,16 @@ 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.failed") {
if (!emittedError && !questionRejected && !formCancelled) {
if (event.type === "session.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
}
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}` }
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
if (!emit("error", time, { error })) UI.error(error.message)
}
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return
}
if (event.type === "session.execution.succeeded") return
}
}
@@ -38,60 +38,54 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
export function legacyTool(input: {
sessionID: string
messageID: string
tool: SessionMessageAssistantTool
callID: string
name: string
state: SessionMessageAssistantTool["state"]
time: SessionMessageAssistantTool["time"]
provider?: SessionMessageAssistantTool["provider"]
}): 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_${tool.id}`,
id: `prt_${input.callID}`,
sessionID: input.sessionID,
messageID: input.messageID,
type: "tool" as const,
callID: tool.id,
tool: tool.name,
callID: input.callID,
tool: input.name,
}
if (tool.state.status === "pending") {
if (input.state.status === "pending") {
return {
...base,
state: { status: "pending", input: {}, raw: tool.state.input },
state: { status: "pending", input: {}, raw: input.state.input },
}
}
if (tool.state.status === "running") {
if (input.state.status === "running") {
return {
...base,
state: {
status: "running",
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 },
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 },
},
}
}
if (tool.state.status === "completed") {
if (input.state.status === "completed") {
return {
...base,
state: {
status: "completed",
input: tool.state.input,
output: outputText(tool.state.content),
title: tool.name,
input: input.state.input,
output: outputText(input.state.content),
title: input.name,
metadata: {
structured: tool.state.structured,
content: tool.state.content,
outputPaths: tool.state.outputPaths,
result: tool.state.result,
providerCall,
providerResult,
structured: input.state.structured,
content: input.state.content,
outputPaths: input.state.outputPaths,
result: input.state.result,
providerCall: input.provider,
},
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
},
}
}
@@ -99,30 +93,19 @@ export function legacyTool(input: {
...base,
state: {
status: "error",
input: tool.state.input,
error: tool.state.error.message,
input: input.state.input,
error: input.state.error.message,
metadata: {
structured: tool.state.structured,
content: tool.state.content,
result: tool.state.result,
providerCall,
providerResult,
structured: input.state.structured,
content: input.state.content,
result: input.state.result,
providerCall: input.provider,
},
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.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 =
@@ -158,7 +141,6 @@ type ToolTrack = {
name: string
input: Record<string, unknown>
started: number
providerState?: Record<string, unknown>
}
type ChildState = {
@@ -175,8 +157,6 @@ 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>
@@ -245,7 +225,6 @@ 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)
@@ -262,8 +241,6 @@ 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(),
@@ -327,7 +304,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const part = legacyTool({
sessionID: child.sessionID,
messageID,
tool: item,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
child.callIDs.add(item.id)
@@ -346,8 +327,6 @@ 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()
@@ -358,44 +337,36 @@ 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") {
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, {
child.text.set(item.id, item.text)
child.projectedText.set(item.id, item.text)
setFrame(child, `text:${item.id}`, {
kind: "assistant",
source: "assistant",
text: item.text,
phase: "progress",
messageID: message.id,
partID: id,
partID: item.id,
})
continue
}
if (item.type === "reasoning") {
const id = `reasoning:${reasoningOrdinal++}`
const key = fragmentKey(message.id, id)
child.reasoning.set(key, item.text)
child.projectedReasoning.set(key, item.text)
child.reasoning.set(item.id, item.text)
child.projectedReasoning.set(item.id, item.text)
if (input.thinking)
setFrame(child, key, {
setFrame(child, `reasoning:${item.id}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${item.text}`,
phase: "progress",
messageID: message.id,
partID: id,
partID: item.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",
@@ -467,90 +438,74 @@ 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 id = currentFragmentID("text", child.textOrdinals, event.data.assistantMessageID)
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedText.get(key)
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.text.get(key) ?? "") + event.data.delta
child.text.set(key, next)
setFrame(child, key, {
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
child.text.set(event.data.textID, next)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: next,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.textID,
})
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.text.ended") {
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, {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
kind: "assistant",
source: "assistant",
text: event.data.text,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.textID,
})
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 id = currentFragmentID("reasoning", child.reasoningOrdinals, event.data.assistantMessageID)
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedReasoning.get(key)
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
return
}
const next = (child.reasoning.get(key) ?? "") + event.data.delta
child.reasoning.set(key, next)
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
child.reasoning.set(event.data.reasoningID, next)
if (!input.thinking) return
setFrame(child, key, {
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${next}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
}
if (event.type === "session.reasoning.ended") {
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)
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
setFrame(child, key, {
setFrame(child, `reasoning:${event.data.reasoningID}`, {
kind: "reasoning",
source: "reasoning",
text: `Thinking: ${event.data.text}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.reasoningID,
})
notifyDetail(child)
return
@@ -562,19 +517,17 @@ 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: current?.name ?? "tool",
name: event.data.tool,
input: event.data.input,
started: current?.started ?? event.created,
providerState: event.data.state,
})
childTool(
child,
{
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
},
@@ -594,9 +547,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
provider: event.data.provider,
state: failed
? {
status: "error",
@@ -626,14 +577,7 @@ 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",
@@ -645,23 +589,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
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"
) {
if (event.type === "session.execution.settled") {
child.status =
event.type === "session.execution.succeeded"
? "completed"
: event.type === "session.execution.interrupted"
? "cancelled"
: "error"
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.created)
input.emit()
}
@@ -683,12 +613,8 @@ 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 (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "session.tool.failed") {
@@ -5,12 +5,13 @@ 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, currentFragmentID, legacyTool, nextFragmentID, toolCommit } from "./stream-v2.subagent"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
@@ -94,7 +95,6 @@ type ToolState = {
input: Record<string, unknown>
started: number
running: boolean
providerState?: Record<string, unknown>
}
type State = {
@@ -106,8 +106,6 @@ 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>
@@ -290,8 +288,6 @@ 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(),
@@ -356,7 +352,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const part = legacyTool({
sessionID: input.sessionID,
messageID,
tool: item,
callID: item.id,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
})
if (item.state.status === "pending") return
if (item.state.status === "running") {
@@ -367,7 +367,6 @@ 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
@@ -444,12 +443,9 @@ 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 id = `text:${textOrdinal++}`
const key = streamPartKey(message.id, id)
const key = streamPartKey(message.id, item.id)
const sent = state.text.get(key)?.length ?? 0
state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text)
@@ -461,14 +457,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: id,
partID: item.id,
},
])
continue
}
if (item.type === "reasoning") {
const id = `reasoning:${reasoningOrdinal++}`
const key = streamPartKey(message.id, id)
const key = streamPartKey(message.id, item.id)
const sent = state.reasoning.get(key)?.length ?? 0
state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text)
@@ -480,15 +475,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress",
messageID: message.id,
partID: id,
partID: item.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([
@@ -603,13 +596,8 @@ 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 id = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
const key = streamPartKey(event.data.assistantMessageID, id)
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -625,14 +613,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.delta,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.textID,
},
])
return
}
if (event.type === "session.text.ended") {
const id = currentFragmentID("text", state.textOrdinals, event.data.assistantMessageID)
const key = streamPartKey(event.data.assistantMessageID, id)
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
write([
@@ -642,20 +629,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.text.slice(previous.length),
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.textID,
},
])
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 id = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
const key = streamPartKey(event.data.assistantMessageID, id)
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -672,14 +654,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress",
messageID: event.data.assistantMessageID,
partID: id,
partID: event.data.reasoningID,
},
])
return
}
if (event.type === "session.reasoning.ended") {
const id = currentFragmentID("reasoning", state.reasoningOrdinals, event.data.assistantMessageID)
const key = streamPartKey(event.data.assistantMessageID, id)
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
write([
@@ -689,7 +670,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: id,
partID: event.data.reasoningID,
},
])
state.reasoning.set(key, event.data.text)
@@ -712,9 +693,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
}
@@ -729,9 +709,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
provider: event.data.provider,
state: failed
? {
status: "error",
@@ -775,8 +753,6 @@ 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 +
@@ -785,45 +761,32 @@ 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.started") {
write([], { phase: "running" })
return
}
if (
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
if (event.type === "session.execution.settled") {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
state.wait = undefined
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
if (current.interrupted) {
current.resolve()
return
}
if (event.type === "session.execution.failed") {
if (event.data.outcome === "failure") {
if (current.failureRendered) {
current.resolve()
return
}
current.reject(new Error(errorMessage(event.data.error)))
return
}
if (event.type === "session.execution.interrupted") {
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed"))
return
}
current.resolve()
@@ -1083,8 +1046,6 @@ 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,20 +32,11 @@ 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_succeeded",
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome },
}
}
@@ -13,19 +13,6 @@ 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",
@@ -345,7 +332,9 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
shell: shell(),
timestamp: 1,
callID: "call-1",
command: "pwd",
},
})
@@ -353,10 +342,10 @@ describe("run session data", () => {
expect.objectContaining({
kind: "tool",
phase: "start",
partID: "shell:sh_1",
partID: "shell:call-1",
tool: "bash",
shell: {
callID: "sh_1",
callID: "call-1",
command: "pwd",
},
}),
@@ -367,8 +356,9 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
})
@@ -376,12 +366,12 @@ describe("run session data", () => {
expect.objectContaining({
kind: "tool",
phase: "progress",
partID: "shell:sh_1",
partID: "shell:call-1",
tool: "bash",
text: "/tmp/demo\n",
toolState: "completed",
shell: {
callID: "sh_1",
callID: "call-1",
command: "pwd",
},
}),
@@ -393,7 +383,9 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
shell: shell(),
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).data
@@ -403,7 +395,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "sh_1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
@@ -420,8 +412,9 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).data
@@ -431,7 +424,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "sh_1",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
@@ -456,7 +449,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "sh_1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
@@ -473,7 +466,9 @@ describe("run session data", () => {
type: "session.shell.started",
properties: {
sessionID: "session-1",
shell: shell(),
timestamp: 1,
callID: "call-1",
command: "pwd",
},
}).commits,
).toEqual([])
@@ -483,7 +478,7 @@ describe("run session data", () => {
tool({
id: "tool-1",
messageID: "msg-1",
callID: "sh_1",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
@@ -505,8 +500,9 @@ describe("run session data", () => {
type: "session.shell.ended",
properties: {
sessionID: "session-1",
shell: shell("exited"),
output: { output: "/tmp/demo\n", cursor: 10, size: 10, truncated: false },
timestamp: 2,
callID: "call-1",
output: "/tmp/demo\n",
},
}).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.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@@ -269,9 +269,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@@ -364,9 +363,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@@ -462,9 +460,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@@ -712,7 +709,7 @@ describe("V2 mini transport", () => {
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "the answer" }],
content: [{ type: "text", id: "txt_1", text: "the answer" }],
time: { created: 2, completed: 3 },
},
],
@@ -731,6 +728,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
delta: "answer",
},
})
@@ -743,7 +741,7 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("scopes text and reasoning ordinals by assistant message", async () => {
test("scopes repeated text and reasoning ids by assistant message", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
@@ -756,8 +754,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "second thought" },
{ type: "text", text: "second answer" },
{ type: "reasoning", id: "reasoning-0", text: "second thought" },
{ type: "text", id: "text-0", text: "second answer" },
],
time: { created: 4, completed: 5 },
},
@@ -767,8 +765,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "first thought" },
{ type: "text", text: "first answer" },
{ type: "reasoning", id: "reasoning-0", text: "first thought" },
{ type: "text", id: "text-0", text: "first answer" },
],
time: { created: 2, completed: 3 },
},
@@ -816,6 +814,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1",
text: "considering",
},
})
@@ -825,52 +824,6 @@ 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())
@@ -912,9 +865,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@@ -979,9 +931,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@@ -1042,9 +993,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@@ -1269,9 +1219,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@@ -1347,9 +1296,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok(undefined) as never
@@ -1418,9 +1366,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_unrelated_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await Bun.sleep(0)
await Bun.sleep(0)
@@ -1440,9 +1387,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_skill_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@@ -1563,6 +1509,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
textID: "txt_child",
delta: "child answer",
},
})
@@ -1572,9 +1519,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_child"),
data: { sessionID: "ses_child" },
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "success" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
await transport.close()
@@ -1630,9 +1576,8 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_child"),
data: { sessionID: "ses_child", reason: "user" },
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
})
await Bun.sleep(0)
resolveGet?.()
@@ -1688,18 +1633,6 @@ 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,
@@ -1709,8 +1642,9 @@ 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 },
executed: true,
provider: { executed: true },
},
})
events.push({
@@ -1724,16 +1658,15 @@ describe("V2 mini transport", () => {
callID: "call_sub",
structured: { sessionID: "ses_child", status: "running", output: "" },
content: [],
executed: true,
provider: { executed: true },
},
})
// The settled event arrives after adoption, so it applies directly.
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.interrupted",
durable: durable("ses_child"),
data: { sessionID: "ses_child", reason: "shutdown" },
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)
@@ -101,6 +101,7 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
},
} satisfies SessionEvent.Event),
)
@@ -114,6 +115,7 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
text: "hello assistant",
},
} satisfies SessionEvent.Event),
@@ -121,7 +123,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", text: "hello assistant" }])
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
})
test.skip("tool completion stores completed timestamp", () => {
@@ -174,9 +176,9 @@ test.skip("tool completion stores completed timestamp", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
executed: true,
state: { source: "provider" },
provider: { executed: true, metadata: { fake: { source: "provider" } } },
},
} satisfies SessionEvent.Event),
)
@@ -193,8 +195,7 @@ test.skip("tool completion stores completed timestamp", () => {
callID,
structured: {},
content: [{ type: "text", text: "/tmp" }],
executed: true,
resultState: { status: "done" },
provider: { executed: true, metadata: { fake: { status: "done" } } },
},
} satisfies SessionEvent.Event),
)
@@ -204,11 +205,7 @@ 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]).toMatchObject({
executed: true,
providerState: { source: "provider" },
providerResultState: { status: "done" },
})
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
})
test("compaction events reduce to compaction message only when completed", () => {
+30
View File
@@ -31,6 +31,36 @@ Configuration supplied for the plugin is available as `ctx.options`.
Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`.
## Tools
Plugins register named tools through `ctx.tool`. The executor returns validated structured output and model-facing content together.
```ts
import { define, Tool } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
export const Plugin = define({
id: "greeting",
effect: (ctx) =>
ctx.tool
.register({
greet: Tool.make({
description: "Greet someone",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ message: Schema.String }),
execute: ({ name }) => {
const message = `Hello ${name}`
return Effect.succeed({
structured: { message },
content: [{ type: "text", text: message }],
})
},
}),
})
.pipe(Effect.orDie),
})
```
## Transform Hooks
Transform hooks contribute to stateful domains:
+21 -50
View File
@@ -38,32 +38,19 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
export type Result<Structured = unknown> = {
readonly structured: Structured
readonly content: ReadonlyArray<Content>
}
type Config<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly output: OutputSchema
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Content>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
) => Effect.Effect<Result<Schema.Schema.Type<OutputSchema>>, ToolFailure>
}
/**
@@ -75,7 +62,7 @@ type DynamicConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure>
readonly execute: (input: unknown, context: Context) => Effect.Effect<Result, ToolFailure>
}
type Runtime = {
@@ -86,23 +73,19 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
export function make<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>>(
config: Config<Input, OutputSchema>,
): Definition<Input, OutputSchema>
export function make(config: DynamicConfig): AnyTool
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
export function make(config: Config<any, any> | DynamicConfig): AnyTool {
if ("jsonSchema" in config) return makeDynamic(config)
return makeTyped(config)
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
function makeTyped<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>>(
config: Config<Input, OutputSchema>,
): Definition<Input, OutputSchema> {
const tool = Object.freeze({}) as Definition<Input, OutputSchema>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
@@ -112,7 +95,7 @@ function makeTyped<
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.structured ?? config.output),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
@@ -123,14 +106,8 @@ function makeTyped<
Effect.flatMap((input) =>
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
Schema.encodeEffect(config.output)(output.structured).pipe(
Effect.map((structured) => ToolOutput.make(structured, output.content.map(toModelContent))),
Effect.mapError(
(error) =>
new ToolFailure({
@@ -139,12 +116,6 @@ function makeTyped<
),
),
),
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
})),
),
),
),
@@ -171,7 +142,7 @@ function makeDynamic(config: DynamicConfig): AnyTool {
settle: (call, context) =>
config
.execute(call.input, context)
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
.pipe(Effect.map((output) => ToolOutput.make(output.structured, output.content.map(toModelContent)))),
})
return tool
}
-1
View File
@@ -20,7 +20,6 @@ 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"
-3
View File
@@ -8,9 +8,6 @@ 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"),
-72
View File
@@ -1,72 +0,0 @@
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
+67 -51
View File
@@ -3,18 +3,16 @@ export * as SessionEvent from "./session-event.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Event } from "./event.js"
import { ToolContent } from "./llm.js"
import { FinishReason } from "./llm.js"
import { ProviderMetadata, ToolContent } from "./llm.js"
import { Delivery } from "./session-delivery.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { NonNegativeInt, 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 }
@@ -43,6 +41,16 @@ 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,
@@ -112,27 +120,15 @@ export const PromptAdmitted = Event.durable({
})
export type PromptAdmitted = typeof PromptAdmitted.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 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 const ContextUpdated = Event.durable({
type: "session.context.updated",
@@ -208,11 +204,11 @@ export namespace Step {
export const Ended = Event.durable({
type: "session.step.ended",
...options,
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
finish: FinishReason,
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
@@ -231,11 +227,11 @@ export namespace Step {
export const Failed = Event.durable({
type: "session.step.failed",
...options,
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
error: SessionError.Error,
error: UnknownError,
},
})
export type Failed = typeof Failed.Type
@@ -248,6 +244,7 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
},
})
export type Started = typeof Started.Type
@@ -258,6 +255,7 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
delta: Schema.String,
},
})
@@ -269,6 +267,7 @@ export namespace Text {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
textID: Schema.String,
text: Schema.String,
},
})
@@ -282,7 +281,8 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
state: SessionMessage.ProviderState.pipe(optional),
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
},
})
export type Started = typeof Started.Type
@@ -293,6 +293,7 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
reasoningID: Schema.String,
delta: Schema.String,
},
})
@@ -304,8 +305,9 @@ export namespace Reasoning {
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
reasoningID: Schema.String,
text: Schema.String,
state: SessionMessage.ProviderState.pipe(optional),
providerMetadata: ProviderMetadata.pipe(optional),
},
})
export type Ended = typeof Ended.Type
@@ -355,9 +357,12 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
executed: Schema.Boolean,
state: SessionMessage.ProviderState.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
},
})
export type Called = typeof Called.Type
@@ -386,8 +391,10 @@ export namespace Tool {
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(optional),
result: Schema.Unknown.pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
},
})
export type Success = typeof Success.Type
@@ -397,27 +404,39 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
error: SessionError.Error,
error: UnknownError,
result: Schema.Unknown.pipe(optional),
executed: Schema.Boolean,
resultState: SessionMessage.ProviderState.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
}),
},
})
export type Failed = typeof Failed.Type
}
export const RetryScheduled = Event.durable({
type: "session.retry.scheduled",
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",
...options,
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
attempt: PositiveInt,
at: NonNegativeInt,
error: SessionError.Error,
attempt: Schema.Finite,
error: RetryError,
},
})
export type RetryScheduled = typeof RetryScheduled.Type
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = Event.durable({
@@ -462,7 +481,7 @@ export namespace RevertEvent {
export const Committed = Event.durable({
type: "session.revert.committed",
...options,
schema: { ...Base, to: SessionMessage.ID },
schema: { ...Base, messageID: SessionMessage.ID },
})
}
@@ -474,10 +493,7 @@ export const Definitions = Event.inventory(
Forked,
PromptPromoted,
PromptAdmitted,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
Execution.Interrupted,
ExecutionSettled,
ContextUpdated,
Synthetic,
Skill.Activated,
@@ -499,7 +515,7 @@ export const Definitions = Event.inventory(
Tool.Progress,
Tool.Success,
Tool.Failed,
RetryScheduled,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
+20 -24
View File
@@ -2,16 +2,14 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ToolContent } from "./llm.js"
import { ProviderMetadata, ToolContent } from "./llm.js"
import { Model } from "./model.js"
import { FileAttachment, Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
import { DateTimeUtcFromMillis, 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"),
@@ -22,17 +20,18 @@ 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,
@@ -45,6 +44,7 @@ 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: SessionError.Error,
error: UnknownError,
result: Schema.Unknown.pipe(optional),
}).annotate({ identifier: "Session.Message.ToolState.Error" })
@@ -137,9 +137,11 @@ export const AssistantTool = Schema.Struct({
type: Schema.Literal("tool"),
id: Schema.String,
name: Schema.String,
executed: Schema.Boolean.pipe(optional),
providerState: ProviderState.pipe(optional),
providerResultState: ProviderState.pipe(optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(optional),
resultMetadata: ProviderMetadata.pipe(optional),
}).pipe(optional),
state: ToolState,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
@@ -152,14 +154,16 @@ 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,
state: ProviderState.pipe(optional),
providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
@@ -171,13 +175,6 @@ 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,
@@ -190,7 +187,7 @@ export const Assistant = Schema.Struct({
end: Schema.String.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
}).pipe(optional),
finish: FinishReason.pipe(optional),
finish: Schema.String.pipe(optional),
cost: Schema.Finite.pipe(optional),
tokens: Schema.Struct({
input: Schema.Finite,
@@ -198,8 +195,7 @@ export const Assistant = Schema.Struct({
reasoning: Schema.Finite,
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
}).pipe(optional),
error: SessionError.Error.pipe(optional),
retry: AssistantRetry.pipe(optional),
error: UnknownError.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
+1 -23
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect"
import { Schema } from "effect"
import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js"
import { Model } from "../src/model.js"
@@ -7,7 +7,6 @@ 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"
@@ -68,25 +67,4 @@ 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")
})
})
+1 -35
View File
@@ -16,8 +16,6 @@ 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"
@@ -101,10 +99,6 @@ 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",
@@ -123,7 +117,7 @@ describe("public event manifest", () => {
"session.tool.failed.1",
"session.reasoning.started.1",
"session.reasoning.ended.1",
"session.retry.scheduled.1",
"session.retried.1",
"session.compaction.started.1",
"session.compaction.ended.1",
"session.revert.staged.1",
@@ -136,32 +130,4 @@ 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)
})
})
@@ -1,51 +0,0 @@
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()
})
+1 -1
View File
@@ -47,7 +47,7 @@ it.live(
description: "Embedded test tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
}),
})
.pipe(Effect.orDie),
+8 -17
View File
@@ -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|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(
/^(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(
name,
) &&
!reachable.has(name)
@@ -100,23 +100,17 @@ 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|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(
/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(
generatedTypes,
)
) {
throw new Error("Session history generated duplicate Session event variants")
}
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) {
const logTypesPatched = generatedTypes.replace(
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
"$1number",
)
if (logTypesPatched === generatedTypes) {
throw new Error("Session log numeric query patch did not apply")
}
const sessionListTypesPatched = logTypesPatched.replace(
@@ -134,15 +128,12 @@ 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: V2EventStream(?:V2)?;?\s*\};?/,
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\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"
+270 -491
View File
@@ -24,10 +24,7 @@ export type Event =
| EventSessionForked
| EventSessionPromptPromoted
| EventSessionPromptAdmitted
| EventSessionExecutionStarted
| EventSessionExecutionSucceeded
| EventSessionExecutionFailed
| EventSessionExecutionInterrupted
| EventSessionExecutionSettled
| EventSessionContextUpdated
| EventSessionSynthetic
| EventSessionSkillActivated
@@ -49,7 +46,7 @@ export type Event =
| EventSessionToolProgress
| EventSessionToolSuccess
| EventSessionToolFailed
| EventSessionRetryScheduled
| EventSessionRetried
| EventSessionCompactionStarted
| EventSessionCompactionDelta
| EventSessionCompactionEnded
@@ -922,32 +919,11 @@ export type GlobalEvent = {
}
| {
id: string
type: "session.execution.started"
type: "session.execution.settled"
properties: {
sessionID: string
}
}
| {
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"
outcome: "success" | "failure" | "interrupted"
error?: SessionErrorUnknown
}
}
| {
@@ -1018,7 +994,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish: string
cost: number
tokens: {
input: number
@@ -1039,7 +1015,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
error: SessionErrorUnknown
}
}
| {
@@ -1048,6 +1024,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
}
}
| {
@@ -1056,6 +1033,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -1065,6 +1043,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -1074,7 +1053,8 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
state?: SessionMessageProviderState
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
| {
@@ -1083,6 +1063,7 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -1092,8 +1073,9 @@ export type GlobalEvent = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
state?: SessionMessageProviderState
providerMetadata?: LlmProviderMetadata
}
}
| {
@@ -1133,11 +1115,14 @@ export type GlobalEvent = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
executed: boolean
state?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
| {
@@ -1166,8 +1151,10 @@ export type GlobalEvent = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
| {
@@ -1177,21 +1164,21 @@ export type GlobalEvent = {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
error: SessionErrorUnknown
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
| {
id: string
type: "session.retry.scheduled"
type: "session.retried"
properties: {
sessionID: string
assistantMessageID: string
attempt: number
at: number
error: SessionStructuredError
error: SessionRetryError
}
}
| {
@@ -1240,7 +1227,7 @@ export type GlobalEvent = {
type: "session.revert.committed"
properties: {
sessionID: string
to: string
messageID: string
}
}
| {
@@ -1736,10 +1723,6 @@ export type GlobalEvent = {
| SyncEventSessionForked
| SyncEventSessionPromptPromoted
| SyncEventSessionPromptAdmitted
| SyncEventSessionExecutionStarted
| SyncEventSessionExecutionSucceeded
| SyncEventSessionExecutionFailed
| SyncEventSessionExecutionInterrupted
| SyncEventSessionContextUpdated
| SyncEventSessionSynthetic
| SyncEventSessionSkillActivated
@@ -1758,7 +1741,7 @@ export type GlobalEvent = {
| SyncEventSessionToolProgress
| SyncEventSessionToolSuccess
| SyncEventSessionToolFailed
| SyncEventSessionRetryScheduled
| SyncEventSessionRetried
| SyncEventSessionCompactionStarted
| SyncEventSessionCompactionEnded
| SyncEventSessionRevertStaged
@@ -2908,10 +2891,6 @@ export type SessionDurableEvent =
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionContextUpdated
| SessionSynthetic
| SessionSkillActivated
@@ -2930,7 +2909,7 @@ export type SessionDurableEvent =
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionRetried
| SessionCompactionStarted
| SessionCompactionEnded
| SessionRevertStaged
@@ -3053,10 +3032,7 @@ export type V2Event =
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionExecutionSettled
| SessionContextUpdated
| SessionSynthetic
| SessionSkillActivated
@@ -3078,7 +3054,7 @@ export type V2Event =
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionRetried
| SessionCompactionStarted
| SessionCompactionDelta
| SessionCompactionEnded
@@ -3295,86 +3271,15 @@ export type PromptAgentAttachment = {
source?: PromptSource
}
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 SessionErrorUnknown = {
type: "unknown"
message: string
}
export type SessionMessageProviderState = {
[key: string]: unknown
export type LlmProviderMetadata = {
[key: string]: {
[key: string]: unknown
}
}
export type ToolTextContent = {
@@ -3391,6 +3296,19 @@ 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"
@@ -3810,64 +3728,6 @@ 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
@@ -3983,7 +3843,7 @@ export type SyncEventSessionStepEnded = {
data: {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish: string
cost: number
tokens: {
input: number
@@ -4011,7 +3871,7 @@ export type SyncEventSessionStepFailed = {
data: {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
error: SessionErrorUnknown
}
}
}
@@ -4027,6 +3887,7 @@ export type SyncEventSessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
}
@@ -4042,6 +3903,7 @@ export type SyncEventSessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -4058,7 +3920,8 @@ export type SyncEventSessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
state?: SessionMessageProviderState
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
}
@@ -4074,8 +3937,9 @@ export type SyncEventSessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
state?: SessionMessageProviderState
providerMetadata?: LlmProviderMetadata
}
}
}
@@ -4126,11 +3990,14 @@ export type SyncEventSessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
executed: boolean
state?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
}
@@ -4173,8 +4040,10 @@ export type SyncEventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
}
@@ -4191,28 +4060,28 @@ export type SyncEventSessionToolFailed = {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
error: SessionErrorUnknown
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
}
export type SyncEventSessionRetryScheduled = {
export type SyncEventSessionRetried = {
type: "sync"
id: string
syncEvent: {
type: "session.retry.scheduled.1"
type: "session.retried.1"
id: string
seq: number
aggregateID: string
data: {
sessionID: string
assistantMessageID: string
attempt: number
at: number
error: SessionStructuredError
error: SessionRetryError
}
}
}
@@ -4288,7 +4157,7 @@ export type SyncEventSessionRevertCommitted = {
aggregateID: string
data: {
sessionID: string
to: string
messageID: string
}
}
}
@@ -4441,6 +4310,7 @@ export type SessionMessageModelSelected = {
}
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type SessionMessageUser = {
@@ -4517,13 +4387,15 @@ export type SessionMessageShell = {
export type SessionMessageAssistantText = {
type: "text"
id: string
text: string
}
export type SessionMessageAssistantReasoning = {
type: "reasoning"
id: string
text: string
state?: SessionMessageProviderState
providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
@@ -4569,7 +4441,7 @@ export type SessionMessageToolStateError = {
structured: {
[key: string]: unknown
}
error: SessionStructuredError
error: SessionErrorUnknown
result?: unknown
}
@@ -4577,9 +4449,11 @@ export type SessionMessageAssistantTool = {
type: "tool"
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState
providerResultState?: SessionMessageProviderState
provider?: {
executed: boolean
metadata?: LlmProviderMetadata
resultMetadata?: LlmProviderMetadata
}
state:
| SessionMessageToolStatePending
| SessionMessageToolStateRunning
@@ -4593,12 +4467,6 @@ export type SessionMessageAssistantTool = {
}
}
export type SessionMessageAssistantRetry = {
attempt: number
at: number
error: SessionStructuredError
}
export type SessionMessageAssistant = {
id: string
metadata?: {
@@ -4617,7 +4485,7 @@ export type SessionMessageAssistant = {
end?: string
files?: Array<string>
}
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish?: string
cost?: number
tokens?: {
input: number
@@ -4628,8 +4496,7 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
error?: SessionErrorUnknown
}
export type SessionMessageCompaction = {
@@ -4801,80 +4668,6 @@ 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
@@ -5019,7 +4812,7 @@ export type SessionStepEnded = {
data: {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish: string
cost: number
tokens: {
input: number
@@ -5051,7 +4844,7 @@ export type SessionStepFailed = {
data: {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
error: SessionErrorUnknown
}
}
@@ -5071,6 +4864,7 @@ export type SessionTextStarted = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -5090,6 +4884,7 @@ export type SessionTextEnded = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -5110,7 +4905,8 @@ export type SessionReasoningStarted = {
data: {
sessionID: string
assistantMessageID: string
state?: SessionMessageProviderState
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
@@ -5130,8 +4926,9 @@ export type SessionReasoningEnded = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
state?: SessionMessageProviderState
providerMetadata?: LlmProviderMetadata
}
}
@@ -5194,11 +4991,14 @@ export type SessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
executed: boolean
state?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
@@ -5249,8 +5049,10 @@ export type SessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
@@ -5271,20 +5073,22 @@ export type SessionToolFailed = {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
error: SessionErrorUnknown
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
export type SessionRetryScheduled = {
export type SessionRetried = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.retry.scheduled"
type: "session.retried"
durable: {
aggregateID: string
seq: number
@@ -5293,10 +5097,8 @@ export type SessionRetryScheduled = {
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
attempt: number
at: number
error: SessionStructuredError
error: SessionRetryError
}
}
@@ -5392,7 +5194,7 @@ export type SessionRevertCommitted = {
location?: LocationRef
data: {
sessionID: string
to: string
messageID: string
}
}
@@ -5923,6 +5725,21 @@ 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
@@ -5934,6 +5751,7 @@ export type SessionTextDelta = {
data: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -5949,6 +5767,7 @@ export type SessionReasoningDelta = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -7008,37 +6827,13 @@ export type EventSessionPromptAdmitted = {
}
}
export type EventSessionExecutionStarted = {
export type EventSessionExecutionSettled = {
id: string
type: "session.execution.started"
type: "session.execution.settled"
properties: {
sessionID: string
}
}
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"
outcome: "success" | "failure" | "interrupted"
error?: SessionErrorUnknown
}
}
@@ -7116,7 +6911,7 @@ export type EventSessionStepEnded = {
properties: {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish: string
cost: number
tokens: {
input: number
@@ -7138,7 +6933,7 @@ export type EventSessionStepFailed = {
properties: {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
error: SessionErrorUnknown
}
}
@@ -7148,6 +6943,7 @@ export type EventSessionTextStarted = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -7157,6 +6953,7 @@ export type EventSessionTextDelta = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -7167,6 +6964,7 @@ export type EventSessionTextEnded = {
properties: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
@@ -7177,7 +6975,8 @@ export type EventSessionReasoningStarted = {
properties: {
sessionID: string
assistantMessageID: string
state?: SessionMessageProviderState
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
@@ -7187,6 +6986,7 @@ export type EventSessionReasoningDelta = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -7197,8 +6997,9 @@ export type EventSessionReasoningEnded = {
properties: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
state?: SessionMessageProviderState
providerMetadata?: LlmProviderMetadata
}
}
@@ -7242,11 +7043,14 @@ export type EventSessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
executed: boolean
state?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
@@ -7277,8 +7081,10 @@ export type EventSessionToolSuccess = {
content: Array<LlmToolContent>
outputPaths?: Array<string>
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
@@ -7289,22 +7095,22 @@ export type EventSessionToolFailed = {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
error: SessionErrorUnknown
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState
provider: {
executed: boolean
metadata?: LlmProviderMetadata
}
}
}
export type EventSessionRetryScheduled = {
export type EventSessionRetried = {
id: string
type: "session.retry.scheduled"
type: "session.retried"
properties: {
sessionID: string
assistantMessageID: string
attempt: number
at: number
error: SessionStructuredError
error: SessionRetryError
}
}
@@ -7359,7 +7165,7 @@ export type EventSessionRevertCommitted = {
type: "session.revert.committed"
properties: {
sessionID: string
to: string
messageID: string
}
}
@@ -8180,6 +7986,7 @@ export type SessionMessageModelSelected2 = {
}
type: "model-switched"
model: ModelRef2
previous?: ModelRef2
}
export type SessionMessageUser2 = {
@@ -8274,17 +8081,21 @@ export type SessionMessageShell2 = {
export type SessionMessageAssistantText2 = {
type: "text"
id: string
text: string
}
export type SessionMessageProviderState2 = {
[key: string]: unknown
export type LlmProviderMetadata2 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionMessageAssistantReasoning2 = {
type: "reasoning"
id: string
text: string
state?: SessionMessageProviderState2
providerMetadata?: LlmProviderMetadata2
time?: {
created: number
completed?: number
@@ -8335,6 +8146,11 @@ export type SessionMessageToolStateCompleted2 = {
result?: unknown
}
export type SessionErrorUnknown2 = {
type: "unknown"
message: string
}
export type SessionMessageToolStateError2 = {
status: "error"
input: {
@@ -8344,7 +8160,7 @@ export type SessionMessageToolStateError2 = {
structured: {
[key: string]: unknown
}
error: SessionStructuredError
error: SessionErrorUnknown2
result?: unknown
}
@@ -8352,9 +8168,11 @@ export type SessionMessageAssistantTool2 = {
type: "tool"
id: string
name: string
executed?: boolean
providerState?: SessionMessageProviderState2
providerResultState?: SessionMessageProviderState2
provider?: {
executed: boolean
metadata?: LlmProviderMetadata2
resultMetadata?: LlmProviderMetadata2
}
state:
| SessionMessageToolStatePending2
| SessionMessageToolStateRunning2
@@ -8368,12 +8186,6 @@ export type SessionMessageAssistantTool2 = {
}
}
export type SessionMessageAssistantRetry2 = {
attempt: number
at: number
error: SessionStructuredError
}
export type SessionMessageAssistant2 = {
id: string
metadata?: {
@@ -8392,7 +8204,7 @@ export type SessionMessageAssistant2 = {
end?: string
files?: Array<string>
}
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish?: string
cost?: number
tokens?: {
input: number
@@ -8403,8 +8215,7 @@ export type SessionMessageAssistant2 = {
write: number
}
}
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry2
error?: SessionErrorUnknown2
}
export type SessionMessageCompaction2 = {
@@ -8579,80 +8390,6 @@ 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
@@ -8815,7 +8552,7 @@ export type SessionStepEnded2 = {
data: {
sessionID: string
assistantMessageID: string
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
finish: string
cost: number
tokens: {
input: number
@@ -8847,7 +8584,7 @@ export type SessionStepFailed2 = {
data: {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
error: SessionErrorUnknown2
}
}
@@ -8867,6 +8604,7 @@ export type SessionTextStarted2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
}
}
@@ -8886,12 +8624,15 @@ export type SessionTextEnded2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
text: string
}
}
export type SessionMessageProviderState3 = {
[key: string]: unknown
export type LlmProviderMetadata3 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionReasoningStarted2 = {
@@ -8910,12 +8651,15 @@ export type SessionReasoningStarted2 = {
data: {
sessionID: string
assistantMessageID: string
state?: SessionMessageProviderState3
reasoningID: string
providerMetadata?: LlmProviderMetadata3
}
}
export type SessionMessageProviderState4 = {
[key: string]: unknown
export type LlmProviderMetadata4 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionReasoningEnded2 = {
@@ -8934,8 +8678,9 @@ export type SessionReasoningEnded2 = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
state?: SessionMessageProviderState4
providerMetadata?: LlmProviderMetadata4
}
}
@@ -8981,8 +8726,10 @@ export type SessionToolInputEnded2 = {
}
}
export type SessionMessageProviderState5 = {
[key: string]: unknown
export type LlmProviderMetadata5 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionToolCalled2 = {
@@ -9002,11 +8749,14 @@ export type SessionToolCalled2 = {
sessionID: string
assistantMessageID: string
callID: string
tool: string
input: {
[key: string]: unknown
}
executed: boolean
state?: SessionMessageProviderState5
provider: {
executed: boolean
metadata?: LlmProviderMetadata5
}
}
}
@@ -9034,8 +8784,10 @@ export type SessionToolProgress2 = {
}
}
export type SessionMessageProviderState6 = {
[key: string]: unknown
export type LlmProviderMetadata6 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionToolSuccess2 = {
@@ -9061,13 +8813,17 @@ export type SessionToolSuccess2 = {
content: Array<LlmToolContent2>
outputPaths?: Array<string>
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState6
provider: {
executed: boolean
metadata?: LlmProviderMetadata6
}
}
}
export type SessionMessageProviderState7 = {
[key: string]: unknown
export type LlmProviderMetadata7 = {
[key: string]: {
[key: string]: unknown
}
}
export type SessionToolFailed2 = {
@@ -9087,20 +8843,35 @@ export type SessionToolFailed2 = {
sessionID: string
assistantMessageID: string
callID: string
error: SessionStructuredError
error: SessionErrorUnknown2
result?: unknown
executed: boolean
resultState?: SessionMessageProviderState7
provider: {
executed: boolean
metadata?: LlmProviderMetadata7
}
}
}
export type SessionRetryScheduled2 = {
export type SessionRetryError2 = {
message: string
statusCode?: number
isRetryable: boolean
responseHeaders?: {
[key: string]: string
}
responseBody?: string
metadata?: {
[key: string]: string
}
}
export type SessionRetried2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.retry.scheduled"
type: "session.retried"
durable: {
aggregateID: string
seq: number
@@ -9109,10 +8880,8 @@ export type SessionRetryScheduled2 = {
location?: LocationRef2
data: {
sessionID: string
assistantMessageID: string
attempt: number
at: number
error: SessionStructuredError
error: SessionRetryError2
}
}
@@ -9208,7 +8977,7 @@ export type SessionRevertCommitted2 = {
location?: LocationRef2
data: {
sessionID: string
to: string
messageID: string
}
}
@@ -9220,10 +8989,6 @@ export type SessionDurableEventV2 =
| SessionForked2
| SessionPromptPromoted2
| SessionPromptAdmitted2
| SessionExecutionStarted2
| SessionExecutionSucceeded2
| SessionExecutionFailed2
| SessionExecutionInterrupted2
| SessionContextUpdated2
| SessionSynthetic2
| SessionSkillActivated2
@@ -9242,7 +9007,7 @@ export type SessionDurableEventV2 =
| SessionToolProgress2
| SessionToolSuccess2
| SessionToolFailed2
| SessionRetryScheduled2
| SessionRetried2
| SessionCompactionStarted2
| SessionCompactionEnded2
| SessionRevertStaged2
@@ -10460,6 +10225,21 @@ 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
@@ -10471,6 +10251,7 @@ export type SessionTextDelta2 = {
data: {
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
@@ -10486,6 +10267,7 @@ export type SessionReasoningDelta2 = {
data: {
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
@@ -11376,10 +11158,7 @@ export type V2EventV2 =
| SessionForked2
| SessionPromptPromoted2
| SessionPromptAdmitted2
| SessionExecutionStarted2
| SessionExecutionSucceeded2
| SessionExecutionFailed2
| SessionExecutionInterrupted2
| SessionExecutionSettled2
| SessionContextUpdated2
| SessionSynthetic2
| SessionSkillActivated2
@@ -11401,7 +11180,7 @@ export type V2EventV2 =
| SessionToolProgress2
| SessionToolSuccess2
| SessionToolFailed2
| SessionRetryScheduled2
| SessionRetried2
| SessionCompactionStarted2
| SessionCompactionDelta2
| SessionCompactionEnded2
+98 -99
View File
@@ -20,7 +20,7 @@ import type {
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
@@ -51,7 +51,6 @@ 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[]>
@@ -70,20 +69,6 @@ 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
@@ -100,7 +85,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
message: {},
permission: {},
question: {},
@@ -116,10 +100,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const statusChanged = new Map<string, number>()
let statusRevision = 0
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
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(
@@ -154,12 +143,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
item.type === "tool" && (callID === undefined || item.id === callID),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined) {
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
},
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
)
},
}
@@ -251,6 +242,7 @@ 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),
@@ -259,21 +251,35 @@ 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)
const existing = position === undefined ? undefined : draft[position]
if (existing?.type === "user") {
if (position === undefined) return
const existing = draft[position]
if (existing?.type === "user" && existing.metadata?.queued === true) {
existing.time.created = event.created
if (existing.metadata?.queued === true) {
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
}
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))
return
}
})
@@ -315,6 +321,7 @@ 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),
@@ -325,6 +332,7 @@ 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
@@ -334,24 +342,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.started":
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
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
}
if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) {
currentAssistant.retry = undefined
currentAssistant.time.completed = event.created
}
if (currentAssistant) currentAssistant.time.completed = event.created
message.append(draft, index, {
id: event.data.assistantMessageID,
type: "assistant",
@@ -364,6 +359,7 @@ 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
@@ -382,26 +378,32 @@ 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))
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
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))
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
if (match) match.text = event.data.text
})
break
@@ -442,8 +444,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
if (!match) return
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.provider = event.data.provider
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
})
break
@@ -472,8 +473,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: [...event.data.content],
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created
})
break
@@ -492,8 +496,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
content: match.state.status === "running" ? match.state.content : [],
result: event.data.result,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created
})
break
@@ -501,57 +508,41 @@ 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: "",
state: event.data.state,
providerMetadata: event.data.providerMetadata,
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))
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
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))
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.reasoningID,
)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) match.state = event.data.state
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
break
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.started":
statusChanged.set(event.data.sessionID, ++statusRevision)
setStore("session", "status", event.data.sessionID, "running")
break
case "session.retried":
case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "")
setSessionStatus(event.data.sessionID, "running")
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
})
case "session.execution.settled":
setSessionStatus(event.data.sessionID, "idle")
break
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -563,10 +554,8 @@ 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),
@@ -687,9 +676,6 @@ 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)
@@ -875,7 +861,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
const activeRevision = statusRevision
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
@@ -894,13 +879,6 @@ 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(),
@@ -922,9 +900,30 @@ 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,13 +57,14 @@ const tui: TuiPlugin = async (api) => {
})
const started = (sessionID: string) => {
active.add(sessionID)
errored.delete(sessionID)
terminal.delete(sessionID)
}
const ended = (sessionID: string) => {
if (terminal.has(sessionID)) return
terminal.add(sessionID)
if (!active.has(sessionID)) return
active.delete(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
@@ -73,25 +74,28 @@ const tui: TuiPlugin = async (api) => {
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
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) => {
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) => {
const sessionID = event.data.sessionID
if (errored.has(sessionID)) {
ended(sessionID)
return
}
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, event.data.error.message, "error")
notify(api, sessionID, "Session error", "error")
ended(sessionID)
})
api.event.on("session.error", (event) => {
const sessionID = event.data.sessionID
if (!sessionID) return
if (api.state.session.status(sessionID)?.type !== "busy") return
if (errored.has(sessionID)) return
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
})
+18 -37
View File
@@ -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, resolvePart, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
addDefaultParsers(parsers.parsers)
@@ -915,9 +915,6 @@ 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
@@ -1095,7 +1092,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
const part = createMemo(() => {
const item = message()
if (item?.type !== "assistant") return
return resolvePart(item, props.partRef.partID)
return item.content.find((part) => part.id === props.partRef.partID)
})
return (
<Show when={part()}>
@@ -1135,7 +1132,7 @@ function SessionGroupView(props: {
refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
const part = message.content.find((part) => part.id === ref.partID)
if (part?.type !== "tool") return []
return [part]
})
@@ -1214,15 +1211,6 @@ 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) }}>
@@ -1243,7 +1231,8 @@ 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())
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
}
return <text fg={theme.textMuted}>{text()}</text>
@@ -1272,15 +1261,9 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
)
}
function CompactionMessage(props: { text?: string }) {
function CompactionMessage() {
const { theme } = useTheme()
return (
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}>
<Show when={props.text}>
<text fg={theme.textMuted}>{props.text}</text>
</Show>
</box>
)
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
}
function statusLabel(status: "added" | "modified" | "deleted") {
@@ -1561,15 +1544,6 @@ 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}>
@@ -2106,14 +2080,16 @@ function Shell(props: ToolProps) {
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
const color = createMemo(() => (permission() ? theme.warning : theme.text))
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 shellID = createMemo(() => stringValue(props.metadata.shellID))
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
})
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() : "")
})
@@ -2156,6 +2132,11 @@ 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>
+40 -47
View File
@@ -28,23 +28,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const boundary = revertBoundary()
return reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages)
partitionPending(rows, pendingPermissions())
return rows
}
createEffect(() => {
const pending = new Set(
function pendingPermissions() {
return new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.callID] : [],
),
)
}
createEffect(() => {
const pending = pendingPermissions()
setRows(
produce((draft) => {
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))
})
partitionPending(draft, pending)
}),
)
})
@@ -69,6 +70,20 @@ 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) => {
@@ -116,20 +131,6 @@ 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
@@ -148,7 +149,6 @@ 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,30 +159,25 @@ 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(latestFragmentRef(event.data.assistantMessageID, "text"))
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(latestFragmentRef(event.data.assistantMessageID, "text"))
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
data.on("session.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(latestFragmentRef(event.data.assistantMessageID, "reasoning"))
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}),
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)
@@ -206,13 +201,11 @@ 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)
append(rows, { messageID: message.id, partID: part.id }, part)
})
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
@@ -220,15 +213,6 @@ 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
}
@@ -255,6 +239,15 @@ 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())
}
+3 -2
View File
@@ -34,11 +34,12 @@ 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,7 +49,6 @@ async function setup() {
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
status: () => ({ type: "busy" }),
},
},
}),
@@ -88,34 +87,46 @@ function durable(sessionID: string) {
return { aggregateID: sessionID, seq: 0, version: 1 }
}
function executionStarted(id: string, sessionID = "session"): V2Event {
function stepStarted(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.execution.started",
durable: durable(sessionID),
data: { sessionID },
}
}
function executionSucceeded(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.execution.succeeded",
durable: durable(sessionID),
data: { sessionID },
}
}
function executionFailed(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.execution.failed",
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
agent: "build",
model: { id: "model", providerID: "provider" },
},
}
}
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
return {
id,
created: 0,
type: "session.step.ended",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
finish,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
}
}
function stepFailed(id: string, sessionID = "session"): V2Event {
return {
id,
created: 0,
type: "session.step.failed",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
error: { type: "unknown", message: "boom" },
},
}
@@ -176,12 +187,12 @@ describe("internal notifications TUI plugin", () => {
])
})
test("notifies for terminal lifecycle events even when attached after execution started", async () => {
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
const harness = await setup()
harness.emit(executionSucceeded("event-1"))
harness.emit(executionStarted("event-2"))
harness.emit(executionSucceeded("event-3"))
harness.emit(stepEnded("event-1"))
harness.emit(stepStarted("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
@@ -190,12 +201,6 @@ 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" },
},
])
})
@@ -203,8 +208,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(executionStarted("event-2", "subagent"))
harness.emit(executionSucceeded("event-3", "subagent"))
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
expect(harness.notifications).toEqual([
{
@@ -225,14 +230,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(executionStarted("event-1"))
harness.emit(executionFailed("event-2"))
harness.emit(executionSucceeded("event-3"))
harness.emit(stepStarted("event-1"))
harness.emit(stepFailed("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
title: "Demo session",
message: "boom",
message: "Session error",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
@@ -242,21 +247,20 @@ describe("internal notifications TUI plugin", () => {
test("special-cases aborts and model response timeouts", async () => {
const harness = await setup()
harness.emit(executionStarted("event-1", "abort"))
harness.emit(stepStarted("event-1", "abort"))
harness.emit({
id: "event-2",
created: 0,
type: "session.error",
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit(executionStarted("event-3", "timeout"))
harness.emit(stepStarted("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([
{
+153 -180
View File
@@ -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, mergeActiveSessionStatus, useData } from "../../../src/context/data"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -28,24 +28,6 @@ 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 = {
@@ -128,12 +110,20 @@ test("refreshes resources into reactive getters", async () => {
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { event: 0, model: 0 }
const requests = { active: 0, event: 0, model: 0 }
let resolveActive!: (response: Response) => void
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({
@@ -176,6 +166,7 @@ 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)
@@ -184,7 +175,25 @@ 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)
@@ -194,6 +203,87 @@ 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
@@ -263,11 +353,9 @@ 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 />
}
@@ -287,15 +375,6 @@ 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,
@@ -308,6 +387,8 @@ 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,
@@ -328,23 +409,16 @@ test("tracks session status from active sessions and execution events", async ()
expect(data.session.status("session-live")).toBe("running")
emitEvent(events, {
id: "evt_execution_succeeded",
id: "evt_execution_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("session-live", 1, 3),
data: { sessionID: "session-live" },
type: "session.execution.settled",
data: {
sessionID: "session-live",
outcome: "success",
},
})
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,
@@ -357,6 +431,8 @@ 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,
@@ -365,146 +441,26 @@ test("tracks session status from active sessions and execution events", async ()
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
error: { type: "provider.content-filter", message: "Provider blocked the response" },
error: { type: "unknown", message: "Provider unavailable" },
},
})
await wait(() => {
const assistant = data.session.message.get("session-failed", "message-failed")
return (
assistant?.type === "assistant" &&
assistant.finish === "error" &&
assistant.error?.type === "provider.content-filter"
)
return assistant?.type === "assistant" && assistant.finish === "error"
})
expect(data.session.status("session-failed")).toBe("running")
emitEvent(events, {
id: "evt_failed_execution_failed",
id: "evt_failed_execution_settled",
created: 0,
type: "session.execution.failed",
durable: durable("session-failed", 1, 3),
type: "session.execution.settled",
data: {
sessionID: "session-failed",
error: { type: "provider.content-filter", message: "Provider blocked the response" },
outcome: "failure",
error: { type: "unknown", message: "Provider unavailable" },
},
})
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()
}
@@ -920,7 +876,18 @@ 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(undefined, events)
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)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@@ -961,7 +928,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" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
},
})
emitEvent(events, {
@@ -997,9 +964,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: {},
executed: false,
state: { call: true },
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitEvent(events, {
@@ -1012,8 +979,7 @@ test("settles pending tools when a live failure arrives", async () => {
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
executed: false,
resultState: { result: true },
provider: { executed: false, metadata: { fake: { result: true } } },
},
})
@@ -1039,14 +1005,21 @@ 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.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true })
expect(tool.providerResultState).toEqual({ result: true })
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { 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()
}
+10 -37
View File
@@ -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", text: "Looking" },
{ type: "text", id: "text-1", 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", text: "Done" },
{ type: "text", id: "text-2", text: "Done" },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
{
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:0" } },
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
])
})
@@ -62,38 +62,20 @@ 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", text: "Looking" },
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "reasoning", text: "" },
{ type: "reasoning", id: "reasoning-2", 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:0" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
{
type: "group",
kind: "exploration",
@@ -113,7 +95,9 @@ 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,
]
@@ -198,17 +182,6 @@ 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",
+12
View File
@@ -34,4 +34,16 @@ 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",
)
})
})
+2 -2
View File
@@ -749,12 +749,12 @@ You can configure file watcher ignore patterns through the `watcher` option.
{
"$schema": "https://opencode.ai/config.json",
"watcher": {
"ignore": ["node_modules/**", "dist/**", ".git/**"]
"ignore": ["**/generated/**"]
}
}
```
Patterns follow glob syntax. Use this to exclude noisy directories from file watching.
Patterns follow glob syntax. Common dependency, VCS, build, and cache directories are ignored automatically at any depth.
---

Some files were not shown because too many files have changed in this diff Show More