chore: merge v2

This commit is contained in:
Aiden Cline 2026-07-06 23:04:26 -05:00
commit 34b4fa9543
156 changed files with 10182 additions and 5610 deletions

View file

@ -1,10 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "992b24b9-f3e9-41f5-87a5-4917d1423169",
"prevIds": [
"96e9fe64-660f-4a73-9414-b38bb7eac290"
],
"id": "b0355fd9-bf41-42e3-9dca-76107de27ecd",
"prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"],
"ddl": [
{
"name": "workspace",
@ -1012,13 +1010,23 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "prompt",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@ -1166,6 +1174,26 @@
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_session_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_message_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
@ -1547,13 +1575,9 @@
"table": "session_share"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1562,13 +1586,9 @@
"table": "workspace"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@ -1577,13 +1597,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1592,13 +1608,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1607,13 +1619,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1622,13 +1630,9 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1637,13 +1641,9 @@
"table": "instruction_checkpoint"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1652,13 +1652,9 @@
"table": "instruction_entry"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1667,13 +1663,9 @@
"table": "message"
},
{
"columns": [
"message_id"
],
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1682,13 +1674,9 @@
"table": "part"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1697,13 +1685,9 @@
"table": "session_input"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1712,13 +1696,9 @@
"table": "session_message"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1727,13 +1707,9 @@
"table": "session"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1742,13 +1718,9 @@
"table": "todo"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@ -1757,184 +1729,140 @@
"table": "session_share"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"key"
],
"columns": ["session_id", "key"],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"session_id",
"position"
],
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
],
"columns": ["name"],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@ -2066,6 +1994,10 @@
"value": "promoted_seq",
"isExpression": false
},
{
"value": "type",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
@ -2078,7 +2010,21 @@
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_input_session_pending_delivery_seq_idx",
"name": "session_input_session_pending_type_delivery_seq_idx",
"entityType": "indexes",
"table": "session_input"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": true,
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
"origin": "manual",
"name": "session_input_session_pending_compaction_idx",
"entityType": "indexes",
"table": "session_input"
},

View file

@ -7,8 +7,11 @@ export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
request: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.",
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}

View file

@ -44,6 +44,9 @@ 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_session_events"),
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703200000_reset_v2_session_events",
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

View file

@ -0,0 +1,39 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706223930_add-session-fork",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
yield* tx.run(`
UPDATE \`session\`
SET
\`parent_id\` = NULL,
\`fork_session_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
),
\`fork_message_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.from')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,43 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260707010146_durable_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -170,8 +170,9 @@ export default {
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
@ -196,6 +197,8 @@ export default {
\`project_id\` text NOT NULL,
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
@ -259,7 +262,10 @@ export default {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,

View file

@ -31,7 +31,8 @@ import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_REQUEST_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
type Transport = StdioClientTransport | StreamableHTTPClientTransport
@ -206,7 +207,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.ignore,
),
)
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
return {
instructions: client.getInstructions()?.trim() || undefined,
tools: () =>
@ -218,11 +220,11 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout: requestTimeout })
return await client.listTools(params, { timeout: catalogTimeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: requestTimeout,
timeout: catalogTimeout,
})
}
},
@ -248,7 +250,7 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: requestTimeout,
timeout: catalogTimeout,
})
},
(result) => result.prompts,
@ -273,7 +275,7 @@ export const connect = Effect.fnUntraced(function* (
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal },
{ signal, timeout: executionTimeout },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
@ -287,8 +289,8 @@ export const connect = Effect.fnUntraced(function* (
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
CallToolResultSchema,
// Keep progress tokens available without imposing a client timeout on tool execution.
{ signal, resetTimeoutOnProgress: true, onprogress: () => {} },
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(

View file

@ -67,7 +67,13 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
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,
@ -201,6 +207,8 @@ const layer = Layer.effect(
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return

View file

@ -13,14 +13,14 @@ import { FSUtil } from "../fs-util"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
export const CustomizeOpencodeContent = customizeOpencodeContent
export const OpencodeContent = opencodeContent
export const ReportContent = reportContent
const CUSTOMIZE_OPENCODE_DESCRIPTION =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
export const OpencodeDescription =
"Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app."
const REPORT_DESCRIPTION =
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
@ -33,10 +33,10 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "customize-opencode",
description: CUSTOMIZE_OPENCODE_DESCRIPTION,
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
name: "opencode",
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
}),
)

View file

@ -1,452 +0,0 @@
<!--
Built-in skill. Name and description are registered in code at
packages/core/src/plugin/skill.ts
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content.
-->
# Customizing opencode
opencode validates its own config strictly and refuses to start when a field
is wrong. The shapes below cover the common surface area, but they are a
**summary, not the source of truth**.
## Full schema reference
The authoritative list of every config option — with field types, enums,
defaults, and descriptions — lives in the published JSON Schema:
**<https://opencode.ai/config.json>**
If a field is not documented in this skill, or you need to confirm an exact
shape before writing config, **fetch that URL and read the schema directly**
rather than guessing. opencode hard-fails on invalid config, so the cost of a
wrong shape is a broken startup.
Independently, every `opencode.json` should declare
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
mistakes as they type.
## Applying changes
Config is loaded once when opencode starts and is not hot-reloaded. After
saving changes to `opencode.json`, an agent file, a skill, a plugin, or any
other config-time file, **tell the user to quit and restart opencode** for
the changes to take effect. The running session will keep using the
already-loaded config until then.
## Where files live
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
Configs from each scope are deep-merged. Project overrides global. Unknown
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
## opencode.json
Every field is optional.
```json
{
"$schema": "https://opencode.ai/config.json",
"username": "string",
"model": "provider/model-id",
"small_model": "provider/model-id",
"default_agent": "agent-name",
"shell": "/bin/zsh",
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
"share": "manual" | "auto" | "disabled",
"autoupdate": true | false | "notify",
"snapshot": true,
"instructions": ["AGENTS.md", "docs/style.md"],
"skills": {
"paths": [".opencode/skills", "/abs/path/to/skills"],
"urls": ["https://example.com/.well-known/skills/"]
},
"references": {
"docs": {
"path": "../docs",
"description": "Use for product behavior and documentation conventions"
},
"sdk": {
"repository": "owner/sdk",
"branch": "main",
"description": "Use for SDK implementation details",
"hidden": true
}
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
"mode": "subagent",
"description": "...",
"permission": { "edit": "deny" }
}
},
"command": {
"deploy": { "description": "...", "template": "..." }
},
"provider": {
"anthropic": { "options": { "apiKey": "..." } }
},
"disabled_providers": ["openai"],
"enabled_providers": ["anthropic"],
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": {}
},
"remote-thing": {
"type": "remote",
"url": "https://...",
"headers": { "Authorization": "Bearer ..." }
}
},
"plugin": [
"opencode-gemini-auth",
"opencode-foo@1.2.3",
"./local-plugin.ts",
["opencode-bar", { "option": "value" }]
],
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "*": "ask" }
},
"formatter": false,
"lsp": false,
"experimental": {
"primary_tools": ["edit"],
"mcp_timeout": 30000
},
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
"compaction": { "auto": true, "tail_turns": 15 }
}
```
Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `command` is an object keyed by command name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
## Skills
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
file is named `SKILL.md` exactly, and lives in its own folder named after the
skill:
```
.opencode/skills/my-skill/SKILL.md
```
Frontmatter:
```markdown
---
name: my-skill
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
---
# My Skill
(skill body in markdown: instructions, examples, references)
```
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
- Optional: `license`, `compatibility`, `metadata` (string-string map).
Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## References
References make local directories and Git repositories outside the active
project available as supporting context. Configure them under `references`,
keyed by the alias used in `@` autocomplete:
```json
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details"
}
}
}
```
Local `path` values may be relative to the declaring config, absolute, or use
`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub
`owner/repo` shorthand; `branch` is optional. Both forms support optional
`description` and `hidden` fields.
- Only references with a `description` are advertised to agents in system context.
- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path.
- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply.
- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories.
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.
### Inline (in `opencode.json`)
```json
{
"agent": {
"my-reviewer": {
"description": "Reviews PRs for style violations.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-6",
"permission": { "edit": "deny", "bash": "ask" },
"prompt": "You are a strict PR reviewer..."
}
}
}
```
### File
```
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
```
```markdown
---
description: Reviews PRs for style violations.
mode: subagent
model: anthropic/claude-sonnet-4-6
permission:
edit: deny
bash: ask
---
You are a strict PR reviewer. Focus on...
```
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
frontmatter.
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
unknown field is silently routed into `options`.
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
file, `disable: true` in frontmatter.
`default_agent` must point to a non-hidden, primary-mode agent.
### Built-in agents
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Commands
opencode's command loader scans for `**/*.md` inside command directories. The
file is named after the command, and lives directly inside the `command` folder:
```
.opencode/command/deploy.md
```
Frontmatter:
```markdown
---
description: One sentence describing what the command does.
agent: build
model: anthropic/claude-sonnet-4-6
---
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
```
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
## Plugins
`plugin:` is an array. Each entry is one of:
```json
"plugin": [
"opencode-gemini-auth", // npm spec, latest
"opencode-foo@1.2.3", // npm spec, pinned
"./local-plugin.ts", // file path, relative to the declaring config
"file:///abs/path/plugin.js", // file URL
["opencode-bar", { "key": "val" }] // tuple form with options
]
```
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
`.opencode/plugin/` or `.opencode/plugins/`.
A plugin module exports `default` (or any named export) of type
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
config: (cfg) => {
// cfg is the live merged config; mutate fields here.
},
"tool.execute.before": async (input, output) => {
// mutate output.args before the tool runs
},
}
}) satisfies Plugin
```
Hook surface (mutate `output` in place; return `void`):
- `event(input)`: every bus event
- `config(cfg)`: once on init with the merged config
- `chat.message`, `chat.params`, `chat.headers`
- `tool.execute.before`, `tool.execute.after`
- `tool.definition`
- `command.execute.before`
- `shell.env`
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
## MCP servers
`mcp:` is an object keyed by server name. Each server is discriminated by
`type`:
```json
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": { "BROWSER": "chromium" }
},
"github": {
"type": "remote",
"url": "https://...",
"enabled": true,
"headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" }
},
"old-server": { "enabled": false }
}
}
```
`command` is an array of strings. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config. String values such as header
tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style
`${VAR}` is not substituted.
## Permissions
```json
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
}
```
Actions: `"allow"`, `"ask"`, `"deny"`.
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
object `{ pattern: action }`. Within an object, **insertion order matters**.
opencode evaluates the LAST matching rule, so put broad rules first and narrow
rules last.
`permission: "allow"` (a string at the top level) is shorthand for "allow
everything" and is rarely what the user wants.
Known permission keys: `read, edit, glob, grep, list, bash, task,
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
skill`. Some of these (`todowrite,
question, webfetch, websearch, doom_loop`) only accept a flat
action, not a per-pattern object.
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
or globs like `~/projects/**`).
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
the `plan` agent's permission ruleset (`edit: deny *`).
## Escape hatches
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
`~/.claude/` and `~/.agents/`.
## When proposing edits
- Validate against the schema before writing. If you are unsure of a field's
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
- For agent, command, skill, and plugin definitions, prefer creating new files
in the correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatches above so they can edit from inside opencode without breaking their
session.
- After saving any config change, remind the user to quit and restart opencode
— running sessions keep using the already-loaded config.

View file

@ -0,0 +1,112 @@
# OpenCode
Use this guide as the starting point for work involving OpenCode itself. It
covers the core concepts needed to configure and customize OpenCode, extend it
with plugins, and build integrations with the OpenCode SDK, clients, and API.
Full documentation is available at <https://opencode.mintlify.site/>. Consult
it when this overview does not contain enough detail for the task.
## Configuration
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
```jsonc
{
"$schema": "https://opencode.ai/config.json"
}
```
Global configuration lives at `~/.config/opencode/opencode.json(c)` and applies
to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches upward from the current directory for project
configuration and merges the files it finds with the global configuration.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
`references`, `formatter`, and `lsp`.
Do not guess field names or shapes. Use
<https://opencode.ai/config.json> as the source of truth and preserve unrelated
settings when editing an existing file.
See the [full configuration guide](https://opencode.mintlify.site/config) for
every field, examples, config locations, and links to dedicated feature guides.
## Service
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
to a background OpenCode service, which owns sessions, configuration, plugins,
permissions, and tool execution.
Configuration and related files are typically watched and reloaded while the
service is running. If a change does not appear, restart the service:
```sh
opencode2 service restart
```
Check its status after restarting:
```sh
opencode2 service status
```
## API
OpenCode exposes an HTTP API from its server. The API is described by an
OpenAPI document available from the running server at `/openapi.json`.
Use OpenCode's built-in `api` command for local requests. It discovers the same
background server used by the TUI, starts it when necessary, and applies the
server's authentication headers automatically.
Call an endpoint with an HTTP method and path:
```sh
opencode2 api get /api/health
```
Pass a request body with `--data` or `-d`, and additional headers with
`--header` or `-H`:
```sh
opencode2 api post /api/example --data '{"key":"value"}'
opencode2 api get /api/example --header 'X-Example:value'
```
Request bodies default to `Content-Type: application/json`. When OpenCode is
connected to an explicit server instead of its managed background service, use
the same configured server and authentication context rather than constructing
an unauthenticated request separately.
See the [full API reference](https://opencode.mintlify.site/api) for available
endpoints, parameters, request bodies, and response schemas. The
raw [OpenAPI specification](https://opencode.mintlify.site/openapi.json) is also
available for code generation and other tooling.
## Troubleshooting
OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode2 service status` and verify the API with
`opencode2 api get /api/health`.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
client startup and `role=server` for sessions, providers, plugins,
permissions, and tools.
- Run one reproduction with `OPENCODE_LOG_LEVEL=DEBUG` when normal logs are not
sufficient.
- Do not delete or edit the database, service registration, or service config
while diagnosing a problem. Back up persistent data before inspecting it
with external tools.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
See the [full troubleshooting guide](https://opencode.mintlify.site/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.

View file

@ -33,7 +33,6 @@ import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
@ -96,6 +95,7 @@ type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
}
@ -125,6 +125,13 @@ export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()(
uri: Schema.String,
message: Schema.String,
}) {}
export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
@ -140,6 +147,7 @@ export type Error =
| OperationUnavailableError
| PromptConflictError
| AttachmentError
| CompactionConflictError
| BusyError
| SkillNotFoundError
| CommandV2.NotFoundError
@ -153,6 +161,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
limit?: number
@ -223,7 +232,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@ -363,6 +372,15 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
remove: Effect.fn("V2Session.remove")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* events.publish(SessionEvent.Deleted, { sessionID })
yield* events.remove(sessionID)
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
@ -529,7 +547,7 @@ const layer = Layer.effect(
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(
SessionEvent.Shell.Started,
@ -616,19 +634,20 @@ const layer = Layer.effect(
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
const session = yield* result.get(input.sessionID)
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions.
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
const context = yield* store.context(input.sessionID)
const compacted = yield* Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
return yield* compaction.compactManual({ session, messages: context })
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInput.admitCompaction(db, events, {
id: inputID,
sessionID: input.sessionID,
}).pipe(
Effect.provide(locations.get(session.location)),
Effect.catch(() => Effect.succeed(false)),
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
return undefined
yield* execution.wake(input.sessionID)
return admitted
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
@ -724,11 +743,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(
input.files,
(file) => materializeAttachment(fs, file),
{ concurrency: 8 },
)
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
@ -746,6 +761,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
@ -754,11 +770,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = Mime.detect(resolved.bytes)
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"),
Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
)
: resolved.bytes
return FileAttachment.create({
@ -788,19 +808,38 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs.stat(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
const info = yield* fs
.stat(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs.readFile(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) }
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
function decodeDataURL(uri: string) {

View file

@ -217,7 +217,13 @@ const make = (dependencies: Dependencies) => {
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
sessionID: input.sessionID,
text: event.text,
})
}
return Effect.void
}),
Effect.as(true),
@ -254,7 +260,9 @@ const make = (dependencies: Dependencies) => {
if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find((message) => message.type === "compaction")
const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead

View file

@ -1,6 +1,7 @@
import { Schema } from "effect"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error"
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
}
}
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
error: SessionError.Error,
}) {
override get message() {
return this.error.message
}
}
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
"Session.UserInterruptedError",
{},
) {
override get message() {
return "Session interrupted by user"
}
}

View file

@ -1,4 +1,4 @@
import { Cause, DateTime, Effect, Exit, Layer } from "effect"
import { Cause, Effect, Exit, Layer } from "effect"
import { EventV2 } from "../../event"
import { LocationServiceMap } from "../../location-service-map"
import { makeGlobalNode } from "../../effect/app-node"
@ -8,6 +8,16 @@ import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
import { toSessionError } from "../to-session-error"
import { UserInterruptedError } from "../error"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
const failure = Cause.squash(exit.cause)
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
return { type: "failed" as const, error: toSessionError(failure) }
}
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
const layer = Layer.effect(
@ -16,7 +26,23 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
Effect.annotateLogs({ sessionID }),
),
),
Effect.asVoid,
)
const coordinator = yield* SessionRunCoordinator.make<
SessionSchema.ID,
SessionRunner.RunError,
"user" | "shutdown" | "superseded"
>({
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@ -29,28 +55,31 @@ const layer = Layer.effect(
),
)
}),
// One ExecutionSettled per execution (busy period), covering every coalesced drain.
settled: (sessionID, exit) =>
Effect.gen(function* () {
const failure =
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(SessionEvent.ExecutionSettled, {
sessionID,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
: undefined,
})
}).pipe(
Effect.catchCause(() => Effect.void),
Effect.asVoid,
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
sessionID,
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
return
}
if (outcome.type === "interrupted") {
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
return
}
yield* events.publish(SessionEvent.Execution.Failed, {
sessionID,
error: outcome.error,
})
}),
),
})
return SessionExecution.Service.of({
active: coordinator.active,
interrupt: coordinator.interrupt,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
resume: coordinator.run,
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,

View file

@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
@ -14,7 +14,13 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()

View file

@ -17,6 +17,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
projectID: ProjectV2.ID.make(row.project_id),
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork: row.fork_session_id
? {
sessionID: SessionSchema.ID.make(row.fork_session_id),
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
}
: undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
? {

View file

@ -2,9 +2,10 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Prompt } from "@opencode-ai/schema/prompt"
@ -13,30 +14,77 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export { Admitted, Delivery }
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
Admitted.make({
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const base = {
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
})
if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id })
return PromptEntry.make({
...base,
type: "prompt",
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
})
}
const toAdmitted = (entry: PromptEntry): Admitted =>
Admitted.make({
admittedSeq: entry.admittedSeq,
id: entry.id,
sessionID: entry.sessionID,
prompt: entry.prompt,
delivery: entry.delivery,
timeCreated: entry.timeCreated,
...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }),
})
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService,
@ -49,7 +97,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
},
) {
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
if (existing !== undefined) {
if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return toAdmitted(existing)
}
return yield* events
.publish(SessionEvent.PromptAdmitted, {
inputID: input.id,
@ -73,11 +124,54 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
),
),
Effect.catchDefect((defect) =>
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
find(db, input.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
db: DatabaseService,
events: EventV2.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* events
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
db: DatabaseService,
input: {
@ -101,6 +195,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
.values({
id: input.id,
session_id: input.sessionID,
type: "prompt",
admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
@ -113,6 +208,44 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService,
input: {
@ -121,6 +254,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
readonly promotedSeq: number
},
) {
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq })
@ -128,6 +262,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
and(
eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
),
)
@ -136,29 +271,58 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
if (stored.type !== "prompt" || stored.sessionID !== input.sessionID)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
}
// Every PromptPromoted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id)
if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq)
if (
!stored ||
stored.type !== "prompt" ||
stored.sessionID !== input.sessionID ||
stored.promotedSeq !== input.promotedSeq
)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
) {
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.handledSeq })
.where(
and(
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.returning()
.get()
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
delivery: Delivery,
) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, delivery),
),
@ -181,42 +345,44 @@ export const equivalent = (
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
const matchesProjection = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
},
) =>
equivalent(input, expected) &&
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) {
for (const row of rows) {
const id = SessionMessage.ID.make(row.id)
yield* events
.publish(SessionEvent.PromptPromoted, {
sessionID,
inputID: id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, id).pipe(
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
)
: Effect.die(defect),
),
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* pendingCompaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.PromptPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, entry.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" && stored.promotedSeq !== undefined
? Effect.void
: Effect.die(defect),
),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
}
return rows.length
return rows.length
}),
)
})
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
@ -224,12 +390,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* pendingCompaction(db, sessionID)) return 0
const rows = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
),
@ -245,12 +413,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "queue"),
),

View file

@ -1,5 +1,5 @@
import { castDraft, produce, type WritableDraft } from "immer"
import { Effect } from "effect"
import { DateTime, Effect } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@ -16,8 +16,10 @@ export interface Adapter {
readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
}
@ -26,6 +28,10 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@ -62,6 +68,13 @@ export function memory(state: MemoryState): Adapter {
})
})
},
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) {
return Effect.sync(() => {
const index = assistantIndex(assistant.id)
@ -80,6 +93,12 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = shell
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) {
return Effect.sync(() => {
state.messages.push(message)
@ -99,11 +118,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
)
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
@ -111,6 +130,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
})
const clearCurrentRetry = Effect.gen(function* () {
const assistant = yield* adapter.getCurrentAssistant()
if (assistant?.retry) {
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
}
})
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.agent.selected": (event) => {
@ -141,10 +171,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.settled": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
@ -154,7 +188,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created },
}),
),
"session.instructions.discovered": () => Effect.void,
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
@ -206,10 +239,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.step.started": (event) => {
return Effect.gen(function* () {
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
if (existing) {
yield* adapter.updateAssistant(
produce(existing, (draft) => {
draft.agent = event.data.agent
draft.model = castDraft(event.data.model)
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
)
return
}
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.retry = undefined
draft.time.completed = event.created
}),
)
@ -245,25 +294,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = "error"
draft.error = event.data.error
draft.error = castDraft(event.data.error)
draft.retry = undefined
})
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
const match = latestText(draft)
if (match) match.text += event.data.delta
})
},
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
const match = latestText(draft)
if (match) match.text = event.data.text
})
},
@ -293,7 +341,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.provider = event.data.provider
match.executed = event.data.executed
match.providerState = event.data.state
match.time.ran = event.created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
@ -319,11 +368,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
@ -342,11 +388,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
match.provider = {
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateError.make({
@ -367,9 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
state: event.data.state,
time: { created: event.created },
}),
),
@ -378,36 +420,83 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
const match = latestReasoning(draft)
if (match) match.text += event.data.delta
})
},
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
const match = latestReasoning(draft)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
if (event.data.state !== undefined) match.state = event.data.state
}
})
},
"session.retried": () => Effect.void,
"session.compaction.started": () => Effect.void,
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return adapter.appendMessage(
"session.retry.scheduled": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.retry = {
attempt: event.data.attempt,
at: DateTime.makeUnsafe(event.data.at),
error: castDraft(event.data.error),
}
})
},
"session.compaction.admitted": (event) =>
adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
id: event.data.inputID,
type: "compaction",
status: "queued",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
}),
)
),
"session.compaction.started": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
}),
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
yield* adapter.updateCompaction({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})
return
}
yield* adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
}),
)
})
},
"session.compaction.failed": () =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
}),
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,

View file

@ -26,7 +26,10 @@ import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<SessionEvent.DurableEvent, typeof SessionEvent.Forked.Type>
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
@ -190,7 +193,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: event.data.parentID,
parent_id: null,
fork_session_id: event.data.parentID,
fork_message_id: event.data.from,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),
@ -243,6 +248,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
),
)
.orderBy(asc(SessionMessageTable.seq))
@ -292,11 +298,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.values(
inputRows.flatMap((row) => {
const id = idMap.get(row.id)
return id
return id && row.type === "prompt"
? [
{
id,
session_id: event.data.sessionID,
type: "prompt" as const,
prompt: row.prompt,
delivery: row.delivery,
admitted_seq: row.admitted_seq,
@ -426,8 +433,30 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "shell" ? message : undefined
})
},
getCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
updateAssistant: updateMessage,
updateShell: updateMessage,
updateCompaction: updateMessage,
appendMessage,
}
yield* SessionMessageUpdater.update(adapter, event)
@ -503,6 +532,9 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionV1.Event.MessageUpdated, (event) =>
Effect.gen(function* () {
const time_created = event.data.info.time.created
@ -634,6 +666,23 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
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.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
@ -660,8 +709,31 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
.update(SessionTable)
@ -687,14 +759,11 @@ const layer = Layer.effectDiscard(
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.id, event.data.messageID),
),
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
)
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`))
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
yield* db
.delete(SessionMessageTable)
.where(

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,
messageID: session.revert.messageID,
to: session.revert.messageID,
})
})

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> {
export interface Coordinator<Key, E, Reason = never> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts an execution while idle, or joins the active execution and returns its exit. */
@ -11,7 +11,7 @@ export interface Coordinator<Key, E> {
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key) => Effect.Effect<void>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@ -23,11 +23,13 @@ export interface Coordinator<Key, E> {
* closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
*/
type Execution<E> = {
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
}
/**
@ -41,19 +43,21 @@ type Execution<E> = {
* waiters get this exit
* ```
*/
export const make = <Key, E>(options: {
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
* Runs in the execution fiber for every exit, including interruption, after the final
* drain and before the execution settles (waiters resolve after it completes).
*/
readonly settled?: (key: Key, exit: Exit.Exit<void, E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
Effect.gen(function* () {
const executions = new Map<Key, Execution<E>>()
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
@ -66,15 +70,25 @@ export const make = <Key, E>(options: {
)
const start = (key: Key, force: boolean) => {
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
settling: false,
}
executions.set(key, execution)
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
// failing self-waking executions from growing the stack across successor starts.
// Drains start one tick after wake; callers observe progress through events or run.
execution.owner = fork(
Effect.yieldNow.pipe(
Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)),
Effect.andThen(loop(key, execution, force)),
Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void),
Effect.onExit((exit) =>
Effect.sync(() => {
execution.settling = true
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
Effect.asVoid,
@ -85,7 +99,7 @@ export const make = <Key, E>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
@ -112,12 +126,13 @@ export const make = <Key, E>(options: {
start(key, false)
})
const interrupt = (key: Key): Effect.Effect<void> =>
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined) return Effect.void
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
execution.stopping = true
execution.pendingWake = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
})

View file

@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error"
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
import { SessionRunnerModel } from "./model"
import type { Instructions } from "../../instructions/index"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {

View file

@ -10,7 +10,8 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
/**
* Runs one durable coding-agent Session until it settles.
@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform"
* - Session ownership and controls
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls.
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
*
* - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
* - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
*
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
@ -137,19 +142,13 @@ const layer = Layer.effect(
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" },
provider: {
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some(
@ -176,6 +175,7 @@ const layer = Layer.effect(
promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
assistantMessageID?: SessionMessage.ID,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
@ -189,7 +189,8 @@ const layer = Layer.effect(
loadInstructions(agent, session.id),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
let needsContinuation = false
let currentStep = step
if (promotion) {
@ -227,7 +228,10 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined,
})
// Automatic compaction completed; rebuild the request from compacted history.
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
@ -236,21 +240,23 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
provider: model.provider,
snapshot: startSnapshot,
assistantMessageID,
})
const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
serialized(publisher.publish(event, outputPaths))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
@ -258,33 +264,49 @@ const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
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,
),
),
),
),
),
).pipe(FiberSet.run(toolFibers))
).pipe(FiberSet.run(toolFibers)),
)
}),
),
Effect.ensuring(serialized(publisher.flush())),
@ -327,64 +349,118 @@ const layer = Layer.effect(
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasAssistantStarted() &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
// error was already recorded from the stream.
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
const error = toSessionError(llmFailure)
if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() &&
(agent.info?.steps === undefined || currentStep < agent.info.steps)
) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
error,
step: currentStep,
})
}
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
// the individual fibers and await all exits before publishing the terminal step event.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause)
const settled = yield* restore(
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
).pipe(Effect.exit)
const settledCauses =
settled._tag === "Failure"
? [settled.cause]
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
const userDeclined = settledCauses.some(isUserDeclined)
const permissionRejected = settledCauses.some(
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
)
if (userDeclined || streamInterrupted || toolsInterrupted) {
if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
if (userDeclined) return yield* Effect.interrupt
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
const settledFailure = settledCauses.find(
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
)
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
if (infraError !== undefined)
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
const error = toSessionError(failure)
yield* serialized(publisher.failUnsettledTools(error))
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
}
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
if (llmFailure && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{
type: "tool.result-missing",
message: "Provider did not return a tool result",
},
true,
),
)
const hostedResultMissing =
stream._tag === "Success" && !providerFailed
? yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
// A provider error orphans recorded local calls; a clean stream can still leave
// hosted calls without results.
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stepFailure) yield* serialized(publisher.publishStepFailure())
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
return yield* Effect.failCause(settled.cause)
if (userDeclined) return yield* Effect.interrupt
if (permissionRejected) return yield* new UserInterruptedError()
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return {
_tag: "Completed",
needsContinuation: !providerFailed && needsContinuation,
@ -405,8 +481,31 @@ const layer = Layer.effect(
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion
let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
while (true) {
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
).pipe(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step + 1
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
})
.pipe(Effect.andThen(Effect.fail(error.cause)))
}),
)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
@ -415,12 +514,36 @@ const layer = Layer.effect(
}
})
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
// drain here.
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
if (!pending) return false
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
)
})
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return
@ -444,11 +567,19 @@ const layer = Layer.effect(
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
}
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = shouldRun ? "queue" : undefined
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
}
})

View file

@ -1,16 +1,19 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly provider: string
readonly snapshot?: string
readonly assistantMessageID?: SessionMessage.ID
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -41,10 +44,10 @@ const message = (value: unknown) => {
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
}
>()
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
let retryEvidence = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
}
| undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create()
assistantActive = true
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
assistantMessageID ??= SessionMessage.ID.create()
stepStarted = true
yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => {
const chunks = new Map<string, string[]>()
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
let nextOrdinal = 0
const start = (id: string) =>
Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
chunks.set(id, [])
return Effect.void
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [] })
return Effect.succeed(ordinal)
})
const append = (id: string, value: string) =>
Effect.suspend(() => {
const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.push(value)
return Effect.void
current.values.push(value)
return Effect.succeed(current.ordinal)
})
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.join(""), providerMetadata)
yield* ended(id, current.values.join(""), current.ordinal, state)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { start, append, end, flush }
}
const text = fragments("text", (textID, value) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID,
text: value,
})
}),
const text = fragments(
"text",
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
})
}),
true,
)
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID,
text: value,
providerMetadata,
})
}),
const reasoning = fragments(
"reasoning",
(_reasoningID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
})
}),
true,
)
const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments()
})
const failAssistant = Effect.fnUntraced(function* (message: string) {
if (assistantFailed) return
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
yield* flush()
yield* startAssistant()
if (replace || stepFailure === undefined) stepFailure = error
})
const publishStepFailure = Effect.fnUntraced(function* () {
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
assistantActive = false
assistantFailed = true
stepFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
assistantMessageID,
error: { type: "unknown", message },
error: stepFailure,
})
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string,
error: SessionError.Error,
hostedOnly = false,
) {
let failed = false
for (const [callID, tool] of tools) {
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: { type: "unknown", message },
provider: {
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
error,
executed: tool.providerExecuted,
})
}
return failed
})
const assistantMessageIDForTool = (callID: string) => {
@ -232,24 +255,27 @@ 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":
yield* text.start(event.id)
retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
textID: event.id,
ordinal: startedTextOrdinal,
})
return
case "text-delta":
yield* text.append(event.id, event.text)
const deltaTextOrdinal = yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID: event.id,
ordinal: deltaTextOrdinal,
delta: event.text,
})
return
@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.end(event.id)
return
case "reasoning-start":
yield* reasoning.start(event.id)
retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
reasoningID: event.id,
providerMetadata: event.providerMetadata,
ordinal: startedReasoningOrdinal,
state: providerState(event.providerMetadata),
})
return
case "reasoning-delta":
yield* reasoning.append(event.id, event.text)
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID: event.id,
ordinal: deltaReasoningOrdinal,
delta: event.text,
})
return
case "reasoning-end":
yield* reasoning.end(event.id, event.providerMetadata)
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
retryEvidence = true
yield* startToolInput(event)
return
case "tool-input-delta": {
@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* endToolInput(event)
return
case "tool-call": {
retryEvidence = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event)
@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true
tool.providerExecuted = event.providerExecuted === true
tool.providerMetadata = event.providerMetadata
const state = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
input: record(event.input),
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
executed: tool.providerExecuted,
state,
})
return
}
case "tool-result": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = settledOutput(event.output, event.result)
const provider = {
executed: event.providerExecuted === true || tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
error: result.error,
result: event.result,
provider,
executed,
resultState,
})
return
}
@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
...result,
outputPaths,
...(provider.executed ? { result: event.result } : {}),
provider,
...(executed ? { result: event.result } : {}),
executed,
resultState,
})
return
}
case "tool-error": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
if (tool.name !== event.name)
@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: event.message },
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
error:
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
: { type: "tool.execution", message: event.message },
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
})
return
}
case "step-finish":
yield* flush()
assistantActive = false
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
if (event.reason === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
return
}
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return
case "finish":
return
case "provider-error":
providerFailed = true
yield* failAssistant(event.message)
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
return
}
})
@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
publish,
flush,
failAssistant,
publishStepFailure,
failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
hasRetryEvidence: () => retryEvidence,
stepFailure: () => stepFailure,
stepSettlement: () => stepSettlement,
startAssistant,
assistantMessageID: assistantMessageIDForTool,

View file

@ -0,0 +1,67 @@
export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
readonly step: number
}> {}
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
return undefined
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.exponential("2 seconds").pipe(
Schedule.take(4),
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay((failure, delay) => {
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
metadata.input instanceof RetryableFailure
? events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
})
: Effect.void,
),
)

View file

@ -41,8 +41,33 @@ const textAttachment = (file: FileAttachment) =>
},
})
const directoryAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
content: [
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
]
.filter((line): line is string => line !== undefined)
.join("\n"),
metadata: {
attachment: {
source: file.source,
name: file.name,
description: file.description,
},
},
})
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)
@ -53,7 +78,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
id: tool.id,
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
@ -62,14 +87,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@ -78,11 +103,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
id: tool.id,
name: tool.name,
result:
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
resultType: "error",
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@ -100,17 +125,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const call = toolCall(
item,
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
)
return result ? [call, result] : [call]
})
@ -120,9 +150,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
.map((item) =>
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
@ -141,9 +176,8 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "user":
const files = message.files ?? []
return [
...files
.filter((file) => file.mime === "text/plain")
.map(textAttachment),
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment),
Message.make({
id: message.id,
role: "user",
@ -175,6 +209,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "assistant":
return assistant(message, model)
case "compaction":
if (message.status !== "completed") return []
return [
Message.make({
id: message.id,

View file

@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
@ -29,6 +30,8 @@ export const SessionTable = sqliteTable(
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),
@ -145,8 +148,9 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>().notNull(),
type: text().$type<SessionInput.Entry["type"]>().notNull(),
prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(),
promoted_seq: integer(),
time_created: integer()
@ -154,12 +158,16 @@ export const SessionInputTable = sqliteTable(
.$default(() => Date.now()),
},
(table) => [
index("session_input_session_pending_delivery_seq_idx").on(
index("session_input_session_pending_type_delivery_seq_idx").on(
table.session_id,
table.promoted_seq,
table.type,
table.delivery,
table.admitted_seq,
),
uniqueIndex("session_input_session_pending_compaction_idx")
.on(table.session_id)
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
],

View file

@ -0,0 +1,55 @@
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 }
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.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
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 }
if (
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError
)
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) }
}

View file

@ -32,6 +32,7 @@ type Active = {
// started after termination resolves immediately from the already-completed deferred.
done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void>
timeout?: (duration: number) => Effect.Effect<void>
}
/**
@ -50,6 +51,8 @@ export interface Interface {
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
@ -124,6 +127,13 @@ export const layer = Layer.effect(
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
@ -265,16 +275,22 @@ export const layer = Layer.effect(
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
if (input.timeout) {
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(input.timeout)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
Effect.catch(() => Effect.void),
),
)
}
)
})
yield* session.timeout(input.timeout)
runFork(
handle.exitCode.pipe(
@ -296,7 +312,7 @@ export const layer = Layer.effect(
return session.info
})
return Service.of({ create, list, get, wait, output, remove })
return Service.of({ create, list, get, wait, timeout, output, remove })
}),
)

View file

@ -75,12 +75,12 @@ export const Plugin = {
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {
const fail = (path: string, error?: unknown) => {
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix })
return new ToolFailure({ message: prefix, error })
}
return Effect.gen(function* () {
const source = {
@ -152,7 +152,7 @@ export const Plugin = {
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
}
const patchFiles = prepared.map(patchFile)
@ -182,11 +182,11 @@ export const Plugin = {
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))),
}).pipe(Effect.mapError((error) => fail(change.path, error))),
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
},
}),
"edit",

View file

@ -113,8 +113,9 @@ export const Plugin = {
error instanceof FileMutation.StaleContentError
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
error,
})
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)

View file

@ -102,7 +102,7 @@ export const Plugin = {
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
),
),
}),

View file

@ -133,7 +133,7 @@ export const Plugin = {
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
}),

View file

@ -76,7 +76,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.andThen(
forms
.ask({

View file

@ -134,7 +134,7 @@ export const Plugin = {
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
return new ToolFailure({ message, error })
}),
)
},

View file

@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
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
@ -45,6 +47,7 @@ export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -86,7 +89,10 @@ const registryLayer = Layer.effect(
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
),
)
let settlement: Settlement
@ -124,20 +130,19 @@ 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 } : {}),
}
})
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration)
if (!registration || registration.identity !== advertised) {
const message = `Stale tool call: ${input.call.name}`
return {
result: {
type: "error" as const,
value: `Stale tool call: ${input.call.name}`,
},
result: { type: "error" as const, value: message },
error: { type: "tool.stale" as const, message },
}
if (registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
}
return yield* settleTool(input, registration.tool)
})
@ -215,7 +220,10 @@ const registryLayer = Layer.effect(
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
}
}),

View file

@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { PluginRuntime } from "../plugin/runtime"
import { PositiveInt } from "../schema"
import { NonNegativeInt } from "../schema"
import { SessionSchema } from "../session/schema"
import { Shell } from "../shell"
import { Tool, type Content } from "./tool"
@ -27,10 +27,10 @@ export const Input = Schema.Struct({
workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}),
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
.pipe(Schema.optional)
.annotate({
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
}),
background: Schema.Boolean.pipe(Schema.optional).annotate({
description:
@ -143,7 +143,7 @@ export const Plugin = {
draft.add(
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.`,
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. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. 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,
@ -191,7 +191,7 @@ export const Plugin = {
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
@ -252,6 +252,7 @@ export const Plugin = {
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
@ -270,7 +271,9 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}),
}
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
}),
),

View file

@ -107,7 +107,7 @@ export const Plugin = {
.get(context.sessionID)
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const agent = yield* agents.resolve(input.agent)
@ -128,7 +128,7 @@ export const Plugin = {
})
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)

View file

@ -48,7 +48,7 @@ export const Plugin = {
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
}),
),
)

View file

@ -172,7 +172,7 @@ export const Plugin = {
format: input.format,
output,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
),
)

View file

@ -246,7 +246,9 @@ export const Plugin = {
text: text ?? NO_RESULTS,
}
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
)
},
}),

View file

@ -85,7 +85,9 @@ export const Plugin = {
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
}).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
"edit",
),

View file

@ -175,7 +175,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
)
const timeout = info.experimental?.mcp_timeout
if (!timeout && !Object.keys(servers).length) return undefined
return { timeout: timeout === undefined ? undefined : { request: timeout }, servers }
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
function migrateMcp(info: ConfigMCPV1.Info) {
@ -187,7 +187,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
cwd: info.cwd,
environment: info.environment,
disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout },
timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
}
return {
type: info.type,
@ -201,7 +201,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
redirect_uri: info.oauth.redirectUri,
},
disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout },
timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
}
}

View file

@ -142,7 +142,7 @@ describe("Config", () => {
// V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}),
)
@ -467,14 +467,14 @@ describe("Config", () => {
},
tool_output: { max_lines: 1000, max_bytes: 32768 },
mcp: {
timeout: { startup: 5000, request: 60000 },
timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: { request: 10000 },
timeout: { catalog: 10000 },
},
remote: {
type: "remote",
@ -552,14 +552,14 @@ describe("Config", () => {
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
expect(documents[0]?.info.mcp).toEqual({
timeout: { startup: 5000, request: 60000 },
timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: {
local: {
type: "local",
command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" },
disabled: false,
timeout: { request: 10000 },
timeout: { catalog: 10000 },
},
remote: {
type: "remote",
@ -792,19 +792,19 @@ describe("Config", () => {
buffer: 10000,
})
expect(documents[0]?.info.mcp).toMatchObject({
timeout: { request: 5000 },
timeout: { catalog: 5000, execution: 5000 },
servers: {
local: {
type: "local",
command: ["node", "server.js"],
disabled: true,
timeout: { request: 10000 },
timeout: { catalog: 10000, execution: 10000 },
},
remote: {
type: "remote",
url: "https://mcp.example.com",
oauth: { client_id: "client", callback_port: 19876 },
timeout: { request: 20000 },
timeout: { catalog: 20000, execution: 20000 },
},
},
})

View file

@ -15,7 +15,10 @@ 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 resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
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,6 +42,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => {
test("resets incompatible V2 Session event 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, [resetSessionEventsMigration])
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")
@ -84,13 +110,14 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
),
).toEqual([
{ name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_input_session_pending_compaction_idx" },
{ name: "session_input_session_pending_type_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" },
@ -132,6 +159,39 @@ describe("DatabaseMigration", () => {
)
})
test("separates existing fork provenance from subagent hierarchy", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`)
yield* db.run(
sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`)
yield* db.run(
sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`,
)
yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration])
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`),
).toEqual({
parent_id: null,
fork_session_id: "ses_source",
fork_message_id: "msg_boundary",
})
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`),
).toEqual({
parent_id: null,
fork_session_id: null,
fork_message_id: null,
})
}),
)
})
test("renames instruction state without losing rows or durable updates", async () => {
await run(
Effect.gen(function* () {
@ -295,7 +355,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
@ -358,6 +418,37 @@ describe("DatabaseMigration", () => {
)
})
test("preserves admitted prompts while generalizing the durable inbox", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
)
yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
expect(
yield* db.all(
sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
),
).toEqual([
{
id: "input",
type: "prompt",
prompt: '{"text":"hello"}',
delivery: "steer",
admitted_seq: 4,
promoted_seq: null,
},
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {

View file

@ -0,0 +1,26 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
})
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(CallToolRequestSchema, async () => {
await Bun.sleep(100)
return { content: [] }
})
server.setRequestHandler(GetPromptRequestSchema, async () => {
await Bun.sleep(100)
return { messages: [] }
})
await server.connect(new StdioServerTransport())

View file

@ -71,9 +71,9 @@ const it = testEffect(
describe("MCP errors", () => {
test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
"failed",
)
expect(
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
).toBe("failed")
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
})
@ -177,6 +177,70 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
test("applies the configured MCP catalog timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"catalog-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
environment: { MCP_TIMEOUT_TARGET: "catalog" },
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
}),
import.meta.dir,
)
return yield* connection.tools()
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
test("applies the configured MCP execution timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"execution-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.callTool({ name: "slow" })
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
test("applies the configured MCP execution timeout to prompts", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"prompt-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.prompt({ name: "slow" })
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
@ -232,7 +296,7 @@ it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] }))
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")

View file

@ -41,8 +41,8 @@ describe("SkillPlugin.Plugin", () => {
expect(skills).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
name: "opencode",
description: expect.stringContaining("any question about OpenCode itself"),
}),
)
expect(skills).toContainEqual(

View file

@ -75,7 +75,7 @@ const it = testEffect(
)
describe("SessionV2.compact", () => {
it.effect("manually compacts the active session context", () =>
it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* SessionV2.Service
@ -95,13 +95,22 @@ describe("SessionV2.compact", () => {
inputID: messageID,
})
yield* session.compact({ sessionID: created.id })
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.CompactionConflictError",
inputID: messageID,
})
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.")
expect(yield* session.context(created.id)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" },
])
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
})
}),
)
})

View file

@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@ -68,11 +68,30 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Objective",
"## Important Details",
"## Work State",
"## Next Move",
])
expect(prompt).toContain("one or two brief sentences")
expect(prompt).toContain("constraints/preferences, decisions and why")
expect(prompt).toContain("Completed:")
expect(prompt).toContain("Active:")
expect(prompt).toContain("Blocked:")
expect(prompt).toContain("immediate concrete action")
expect(prompt).toContain("next action if known")
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const events = yield* EventV2.Service
const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction")
const userMessage = {
@ -108,7 +127,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
),
)
const delta = yield* events
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

View file

@ -206,7 +206,8 @@ describe("SessionV2.create", () => {
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
expect(forked.parentID).toBeUndefined()
expect(forkContext).toMatchObject([
{ type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id },
@ -264,6 +265,7 @@ describe("SessionV2.create", () => {
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } })

View file

@ -0,0 +1,88 @@
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 open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
message: "rate",
})
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 the permission rejection type without exposing internal fields", () => {
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
expect(toSessionError(blocked)).toEqual({
type: "permission.rejected",
message: "Permission denied: external_directory",
})
expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
type: "permission.rejected",
message: "Permission denied: external_directory",
})
})
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])
})
})

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { LLMError, TransportReason } from "@opencode-ai/llm"
import { terminal } from "@opencode-ai/core/session/execution/local"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Effect, Exit } from "effect"
describe("SessionExecutionLocal lifecycle", () => {
test("classifies success and typed failure terminals", () => {
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect(
terminal(
Exit.fail(
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(terminal(Exit.fail(storage))).toEqual({
type: "failed",
error: { type: "unknown", message: storage.message },
})
})
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
const interrupted = Effect.runSyncExit(Effect.interrupt)
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
})
})

View file

@ -101,7 +101,7 @@ describe("SessionProjector", () => {
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
messageID: boundary,
to: boundary,
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@ -437,6 +437,73 @@ describe("SessionProjector", () => {
}),
)
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
attempt: 2,
at: 2_000,
error: { type: "provider.transport", message: "Disconnected" },
})
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
const firstRow = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, first))
.get()
.pipe(Effect.orDie)
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
expect(decode(projected)).toMatchObject({
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
attempt: 3,
at: 6_000,
error: { type: "provider.internal", message: "Unavailable" },
})
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
expect(decode(rows[0])).not.toHaveProperty("retry")
expect(decode(rows[1])).not.toHaveProperty("retry")
}),
)
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@ -530,7 +597,7 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
textID: "text-stale",
ordinal: 0,
})
const rows = yield* db
@ -549,7 +616,7 @@ describe("SessionProjector", () => {
type: "assistant",
agent: "build",
model,
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
SessionMessage.Assistant.make({

View file

@ -246,7 +246,9 @@ describe("SessionV2.prompt", () => {
mention: { start: 8, end: 17, text: "[Image 1]" },
},
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("prompt")
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
}),
)
@ -275,31 +277,35 @@ describe("SessionV2.prompt", () => {
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe(
'import { describe, expect } from "bun:test"',
)
expect(
Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
.toString("utf8")
.replace(/\r$/, ""),
).toBe('import { describe, expect } from "bun:test"')
}),
)
it.effect("rejects directories as file attachments", () =>
it.effect("materializes directories as directory attachments", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const uri = pathToFileURL(import.meta.dir).href
const error = yield* session
.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
resume: false,
})
.pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri,
message: `Attachment is not a file: ${uri}`,
const message = yield* session.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
resume: false,
})
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
)
@ -332,7 +338,8 @@ describe("SessionV2.prompt", () => {
name: "image.png",
},
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
const stored = yield* admitted(message.id)
expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files)
}),
)
@ -565,7 +572,12 @@ describe("SessionV2.prompt", () => {
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false })
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
resume: false,
})
yield* Effect.all(
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
@ -677,7 +689,12 @@ describe("SessionV2.prompt", () => {
.pipe(Effect.orDie)
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false })
.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
resume: false,
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })

View file

@ -0,0 +1,62 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("SessionV2.remove", () => {
it.effect("removes a session and its children", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
yield* session.remove(parent.id)
expect((yield* session.list()).data).toEqual([])
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
}),
)
it.effect("fails when the session does not exist", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const sessionID = SessionV2.ID.make("ses_missing")
expect(yield* Effect.result(session.remove(sessionID))).toMatchObject({
_tag: "Failure",
failure: { _tag: "Session.NotFoundError", sessionID },
})
}),
)
})

View file

@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
Effect.gen(function* () {
const failure = new Error("failed")
const defect = new Error("defect")
const settled: Exit.Exit<void, Error>[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
})
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
const died = yield* coordinator.run("defect").pipe(Effect.exit)
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(settled).toHaveLength(2)
}),
),
)
it.effect("preserves settlement hook defects while releasing ownership", () =>
Effect.scoped(
Effect.gen(function* () {
const defect = new Error("terminal publication failed")
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.void,
settled: () => Effect.die(defect),
})
const exit = yield* coordinator.run("session").pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
expect(yield* coordinator.active).toEqual(new Set())
}),
),
)
@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () => Effect.void,
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
yield* coordinator.interrupt("session", "user")
yield* coordinator.run("session")
expect(reasons).toEqual([undefined])
}),
),
)
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () => Effect.void,
settled: (_key, _exit, reason) =>
Deferred.succeed(settling, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
),
})
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(settling)
yield* coordinator.interrupt("session", "user")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
yield* coordinator.run("session")
expect(reasons).toEqual([undefined, undefined])
}),
),
)
@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session")
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(interrupted)
const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1)
expect(reasons).toEqual(["user"])
}),
),
)
@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
let starts = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
: Deferred.succeed(secondStarted, undefined),
),
),
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
})
yield* coordinator.wake("session")
@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
expect(starts).toBe(2)
}),
),
)
@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
const gate = yield* Deferred.make<void>()
const idle = yield* Deferred.make<void>()
let drains = 0
let starts = 0
const settled: Exit.Exit<void, never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: () =>
@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
),
Effect.asVoid,
),
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
settled: (_key, exit) =>
Effect.sync(() => void settled.push(exit)).pipe(
Effect.andThen(Deferred.succeed(idle, undefined)),
@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
yield* Deferred.await(idle)
expect(drains).toBe(2)
expect(starts).toBe(1)
expect(settled).toHaveLength(1)
expect(Exit.isSuccess(settled[0]!)).toBe(true)
}),

View file

@ -27,17 +27,14 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
assistant("empty-reasoning", [
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
assistant("reasoning", [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning",
text: "",
providerMetadata: { anthropic: { signature: "sig_1" } },
state: { signature: "sig_1" },
}),
]),
],
@ -109,6 +106,7 @@ describe("toLLMMessages", () => {
SessionMessage.Compaction.make({
id: id("compaction"),
type: "compaction",
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "Recent work",
@ -220,6 +218,35 @@ Recent work
])
})
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-directory"),
type: "user",
text: "Review this directory",
files: [directory],
time: { created },
}),
],
model,
)
expect(messages).toHaveLength(2)
expect(messages[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }],
metadata: { attachment: { source: directory.source, name: "src/" } },
})
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
@ -258,12 +285,11 @@ Recent work
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
state: { signature: "sig_1" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@ -308,11 +334,9 @@ Recent work
type: "tool",
id: "hosted",
name: "web_search",
provider: {
executed: true,
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
executed: true,
providerState: { continuation: "hosted-call" },
providerResultState: { continuation: "hosted-result" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@ -325,7 +349,8 @@ Recent work
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
executed: true,
providerState: { continuation: "failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
@ -345,7 +370,7 @@ Recent work
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
{ type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
{
@ -360,14 +385,14 @@ Recent work
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } },
providerMetadata: { provider: { continuation: "hosted-call" } },
},
{
type: "tool-result",
id: "hosted",
name: "web_search",
providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } },
providerMetadata: { provider: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" },
},
{
@ -376,14 +401,14 @@ Recent work
name: "write",
input: { path: "README.md" },
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
providerMetadata: { provider: { continuation: "failed" } },
},
{
type: "tool-result",
id: "hosted-failed",
name: "write",
providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } },
providerMetadata: { provider: { continuation: "failed" } },
result: {
type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
@ -417,9 +442,8 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
}),
],
time: { created, completed: created },
@ -432,7 +456,7 @@ Recent work
{
type: "reasoning",
text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
})
@ -448,19 +472,16 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed",
text: "Partial thought",
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "call_failed" } },
resultMetadata: { openai: { itemId: "result_failed" } },
},
executed: true,
providerState: { itemId: "call_failed" },
providerResultState: { itemId: "result_failed" },
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
@ -520,19 +541,16 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
state: { signature: "sig_old" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-old-model",
name: "web_search",
provider: {
executed: true,
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
executed: true,
providerState: { itemId: "hosted-old-model" },
providerResultState: { itemId: "hosted-old-model" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
@ -546,11 +564,9 @@ Recent work
type: "tool",
id: "local-old-model",
name: "read",
provider: {
executed: false,
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
executed: false,
providerState: { call: "old" },
providerResultState: { result: "old" },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
@ -620,9 +636,8 @@ Recent work
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-alias",
text: "Visible thought",
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
state: { reasoningEncryptedContent: "encrypted" },
}),
],
time: { created, completed: created },
@ -635,7 +650,7 @@ Recent work
{
type: "reasoning",
text: "Visible thought",
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } },
providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } },
},
])
})

View file

@ -45,6 +45,7 @@ const capture = () => {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
provider: "openai",
}),
}
}
@ -88,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
})
})
test("provider-executed success retains its compatibility result", async () => {
test("provider-executed success retains its raw provider result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
@ -96,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => {
expect(success?.data).toHaveProperty("result")
})
test("provider state uses the route provider instead of the catalog provider", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
publisher.publish(
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
),
)
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
state: { itemId: "reasoning" },
})
})
test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
@ -112,7 +126,7 @@ test("binary failure emits no success event", async () => {
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
test("success event data can carry a provider-executed result", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
@ -120,7 +134,7 @@ test("old success event data containing result still decodes", () => {
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
provider: { executed: false },
executed: true,
})
expect(decoded.result).toMatchObject({ type: "content" })
})
@ -133,3 +147,40 @@ 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 retains failure evidence until step closeout", 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"])
await Effect.runPromise(publisher.publishStepFailure())
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 },
),
)
await Effect.runPromise(publisher.publishStepFailure())
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" },
})
})

File diff suppressed because it is too large Load diff

View file

@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
provider: { executed: false },
executed: false,
})
})
@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
callID: "call-success",
structured: { phase: "done" },
content: content("complete"),
provider: { executed: false },
executed: false,
})
expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },
provider: { executed: false },
executed: false,
})
expect((yield* readAssistant).content[1]).toMatchObject({
state: {

View file

@ -103,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreIntegration.Ref, Integration.Ref],
[coreLocation.Ref, Location.Ref],
[coreLLM.ProviderMetadata, LLM.ProviderMetadata],
[coreLLM.FinishReason, LLM.FinishReason],
[coreLLM.ToolTextContent, LLM.ToolTextContent],
[coreLLM.ToolFileContent, LLM.ToolFileContent],
[coreLLM.ToolContent, LLM.ToolContent],
@ -137,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Delivery, SessionInput.Delivery],
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User],
@ -183,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => {
test("shared record schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)

View file

@ -47,7 +47,15 @@ const permission = Layer.succeed(
}).pipe(
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),

View file

@ -40,7 +40,15 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),

View file

@ -23,7 +23,17 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
@ -82,7 +92,13 @@ describe("QuestionTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
).toEqual({
result: { type: "error", value: "Permission denied: question" },
error: {
type: "permission.rejected",
message: "Permission denied: question",
},
})
expect(capturedInput()).toBeUndefined()
deny = false
}),

View file

@ -81,7 +81,19 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
}).pipe(
Effect.andThen(
allow
? Effect.void
: Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
),
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -47,7 +47,15 @@ const permission = Layer.succeed(
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
const session = yield* store.get(id)
if (!session) return
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_shell_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID: id,
assistantMessageID,
@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, {
sessionID: id,
assistantMessageID,
textID,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id,
assistantMessageID,
textID,
ordinal: 0,
text: "ok",
})
yield* events.publish(SessionEvent.Step.Ended, {
@ -435,7 +442,10 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(registry, call({ command: idleCommand, background: true }))
const settled = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }),
)
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 })
@ -445,7 +455,45 @@ describe("ShellTool", () => {
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("updates and clears a running shell timeout", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* settleTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
const cleared = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
yield* shell.timeout(clearedShellID, 0)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(clearedShellID)).status).toBe("running")
yield* shell.remove(clearedShellID)
}),
)
},
@ -462,9 +510,10 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
Effect.forkIn(scope, { startImmediately: true }),
)
const waiting = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
Effect.gen(function* () {
@ -475,7 +524,6 @@ describe("ShellTool", () => {
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
@ -493,6 +541,8 @@ describe("ShellTool", () => {
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id)
}),

View file

@ -55,7 +55,17 @@ describe("SkillTool", () => {
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),

View file

@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
}
completed.add(sessionID)
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_subagent_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
textID,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
textID,
ordinal: 0,
text: childText,
})
yield* events.publish(SessionEvent.Step.Ended, {

View file

@ -33,7 +33,17 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),

View file

@ -38,7 +38,15 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),