Compare commits

..

6 Commits

Author SHA1 Message Date
opencode-agent[bot] 1dc54945d5 fix(tui): sync durable session model changes 2026-07-07 22:50:17 +00:00
Aiden Cline 3100701488 fix(tui): restore prompt context usage (#35795) 2026-07-07 17:49:33 -05:00
Kit Langton 9c54291ae8 fix(tui): restore forms on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:47:13 +00:00
Dax Raad 52ad916ba4 fix(tui): restore permissions on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:44:26 +00:00
Dax Raad 5db320b02d refactor: rename apply_patch tool 2026-07-07 16:27:25 -04:00
Aiden Cline 910e37f6d8 fix: update v2 session usage metrics (#35468) 2026-07-07 14:31:54 -05:00
64 changed files with 1445 additions and 435 deletions
@@ -20,7 +20,7 @@ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{
name: "multi patch",
tool: "apply_patch",
tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(),
assistantMessage(
[
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"),
],
{ completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"running",
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
tool: "patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
@@ -131,7 +131,7 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "apply_patch"
(tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([
toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts"] },
{
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
assistantMessage([
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: files.map((file) => file.filePath) },
{ metadata: { files } },
@@ -246,7 +246,7 @@ function editPart(id: string) {
function patchPart(id: string) {
return toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts", "src/b.ts"] },
{
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
@@ -90,7 +90,7 @@ function questionInput() {
function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
@@ -120,7 +120,7 @@ function toolPart(
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
@@ -1245,7 +1245,7 @@ export function MessageTimeline(props: {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool)
}
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
+2 -2
View File
@@ -628,11 +628,11 @@ function emitEdit(state: State): void {
function emitPatch(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "apply_patch", {
const ref = make(state, "patch", {
patchText: "*** Begin Patch\n*** End Patch",
})
doneTool(state, ref, {
title: "apply_patch",
title: "patch",
output: "",
metadata: {
files: [
+2 -2
View File
@@ -119,7 +119,7 @@ type ToolName =
| "bash"
| "write"
| "edit"
| "apply_patch"
| "patch"
| "batch"
| "task"
| "todowrite"
@@ -1094,7 +1094,7 @@ const TOOL_RULES = {
},
permission: permEdit,
},
apply_patch: {
patch: {
view: {
output: false,
final: true,
@@ -1439,6 +1439,13 @@ export type SessionLogOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly error: { readonly type: string; readonly message: string }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| {
@@ -4542,6 +4549,23 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.usage.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| {
readonly id: string
readonly created: number
@@ -4765,6 +4789,13 @@ export type EventSubscribeOutput =
readonly sessionID: string
readonly assistantMessageID: string
readonly error: { readonly type: string; readonly message: string }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| {
+1 -1
View File
@@ -32,7 +32,7 @@ test("exposes every standard HTTP API group", () => {
"vcs",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug)).toEqual(["location", "evictLocation"])
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([
"list",
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
JSONValue,
LanguageModelV3,
LanguageModelV3CallOptions,
LanguageModelV3FinishReason,
LanguageModelV3FunctionTool,
LanguageModelV3Message,
LanguageModelV3Prompt,
@@ -624,8 +625,8 @@ function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["us
return Object.values(output).some((value) => value !== undefined) ? output : undefined
}
function finishReason(value: unknown): FinishReason {
return Schema.is(FinishReason)(value) ? value : "unknown"
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
return value.unified === "other" ? "unknown" : value.unified
}
function providerMetadata(value: unknown) {
-127
View File
@@ -1,127 +0,0 @@
export * as CodeModeV2 from "./code-mode"
import type { CodeMode } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "./effect/app-node"
import { Flag } from "./flag/flag"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./code-mode/execute"
import { permission, RegistrationError, type AnyTool } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
type Registration = {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly path: readonly [string, ...string[]]
}
export interface MaterializeInput {
readonly permissions?: PermissionV2.Ruleset
}
export interface Interface {
readonly enabled: boolean
readonly register: (
source: (draft: CodeMode.Draft) => void,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly materialize: (input: MaterializeInput) => Effect.Effect<AnyTool | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const enabled = Flag.CODEMODE_ENABLED
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
return Service.of({
enabled,
register: Effect.fn("CodeMode.register")(function* (source) {
const pending: Array<{ readonly path: readonly [string, ...string[]]; readonly tool: AnyTool }> = []
yield* Effect.sync(() =>
source({
add: (path, tool) => pending.push({ path, tool }),
}),
)
if (pending.length === 0) return
const entries = pending.map((entry) => ({
...entry,
key: entry.path.join("\0"),
name: entry.path.join("_"),
}))
const invalid = entries.find((entry) => entry.path.some((segment) => segment.length === 0))
if (invalid)
return yield* new RegistrationError({
name: invalid.path.join("."),
message: "Code Mode paths cannot contain empty segments",
})
const keys = new Set<string>()
const duplicate = entries.find((entry) => {
if (keys.has(entry.key)) return true
keys.add(entry.key)
return false
})
if (duplicate)
return yield* new RegistrationError({
name: duplicate.path.join("."),
message: `Duplicate Code Mode path: ${duplicate.path.join(".")}`,
})
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
identity: {},
tool: entry.tool,
name: entry.name,
path: entry.path,
},
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
)
}),
)
}),
materialize: Effect.fn("CodeMode.materialize")(function* (input) {
if (!enabled || whollyDisabled("execute", input.permissions ?? [])) return undefined
const registrations = new Map<string, Registration>()
for (const [key, entries] of local) {
const registration = entries.at(-1)?.registration
if (
registration &&
!whollyDisabled(permission(registration.tool, registration.name), input.permissions ?? [])
)
registrations.set(key, registration)
}
if (registrations.size === 0) return undefined
return ExecuteTool.create({
registrations,
current: (key) => local.get(key)?.at(-1)?.registration,
})
}),
})
}),
)
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+1 -1
View File
@@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add snapshots / undo after V2 snapshot design exists.
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
// TODO: Design multi-file transactions / rollback if patch needs atomic edits.
// Until then, edits are sequential and report partial application.
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
-2
View File
@@ -2,7 +2,6 @@ import { Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CodeModeV2 } from "./code-mode"
import { CommandV2 } from "./command"
import { Config } from "./config"
import { LayerNode } from "./effect/layer-node"
@@ -120,7 +119,6 @@ const locationServiceNodes = [
MCP.node,
PermissionV2.node,
ToolOutputStore.node,
CodeModeV2.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
Image.node,
+13 -17
View File
@@ -1,7 +1,7 @@
export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { CodeModeV2 } from "../code-mode"
import { Flag } from "../flag/flag"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@@ -15,10 +15,10 @@ const Summary = Schema.Struct({
})
type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
...(codeMode
...(Flag.CODEMODE_ENABLED
? [
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
]
@@ -27,10 +27,10 @@ const entries = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
" </server>",
])
const render = (servers: ReadonlyArray<Summary>, codeMode: boolean) =>
["<mcp_instructions>", ...entries(servers, codeMode), "</mcp_instructions>"].join("\n")
const render = (servers: ReadonlyArray<Summary>) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>, codeMode: boolean) => {
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const diff = Instructions.diffByKey(
previous,
current,
@@ -41,15 +41,12 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [
"The available MCP server instructions have changed. This list supersedes the previous one.",
render(current, codeMode),
render(current),
].join("\n")
return [
...(diff.added.length === 0
? []
: [
"New MCP server instructions are available in addition to those previously listed:",
...entries(diff.added, codeMode),
]),
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
...(diff.removed.length === 0
? []
: [
@@ -67,14 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const mcp = yield* MCP.Service
return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
if (codeMode.enabled && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return Instructions.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
@@ -84,7 +80,7 @@ export const layer = Layer.effect(
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return (
(!codeMode.enabled && owned.length === 0) ||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
@@ -97,8 +93,8 @@ export const layer = Layer.effect(
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible),
baseline: (servers) => render(servers, codeMode.enabled),
update: (previous, current) => update(previous, current, codeMode.enabled),
baseline: render,
update,
removed: () => "MCP server instructions are no longer available.",
})
}),
@@ -106,4 +102,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CodeModeV2.node, MCP.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
-2
View File
@@ -7,7 +7,6 @@ import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CodeModeV2 } from "./code-mode"
import { CommandV2 } from "./command"
import { EventV2 } from "./event"
import { Integration } from "./integration"
@@ -125,7 +124,6 @@ export const node = makeLocationNode({
AgentV2.node,
AISDK.node,
Catalog.node,
CodeModeV2.node,
CommandV2.node,
Integration.node,
Location.node,
-5
View File
@@ -6,7 +6,6 @@ import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CodeModeV2 } from "../code-mode"
import { CommandV2 } from "../command"
import { Credential } from "../credential"
import { EventV2 } from "../event"
@@ -29,7 +28,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const codeMode = yield* CodeModeV2.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service
@@ -160,9 +158,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
callback(draft)
}),
},
codemode: {
register: codeMode.register,
},
event: {
subscribe: () => events.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
@@ -143,6 +143,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
@@ -296,6 +297,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
draft.finish = "error"
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
draft.cost = event.data.cost
draft.tokens = castDraft(event.data.tokens)
}
})
},
"session.text.started": (event) => {
+60 -47
View File
@@ -1,7 +1,7 @@
export * as SessionProjector from "./projector"
import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node"
@@ -48,11 +48,6 @@ type Usage = {
const ForkBatchSize = 500
const emptyUsage = (): Usage => ({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
const forkTitle = (value: string) => {
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
@@ -67,22 +62,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] |
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
}
function addUsage(target: Usage, value: Usage) {
target.cost += value.cost
target.tokens.input += value.tokens.input
target.tokens.output += value.tokens.output
target.tokens.reasoning += value.tokens.reasoning
target.tokens.cache.read += value.tokens.cache.read
target.tokens.cache.write += value.tokens.cache.write
}
function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined {
if (row.type !== "assistant") return undefined
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined
return { cost: message.cost, tokens: message.tokens }
}
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
return {
id: info.id,
@@ -151,6 +130,37 @@ function applyUsage(
.pipe(Effect.orDie)
}
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
) {
const row = yield* db
.select({
cost: SessionTable.cost,
input: SessionTable.tokens_input,
output: SessionTable.tokens_output,
reasoning: SessionTable.tokens_reasoning,
cacheRead: SessionTable.tokens_cache_read,
cacheWrite: SessionTable.tokens_cache_write,
})
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return
yield* events.publish(SessionEvent.UsageUpdated, {
sessionID,
cost: row.cost,
tokens: {
input: row.input,
output: row.output,
reasoning: row.reasoning,
cache: { read: row.cacheRead, write: row.cacheWrite },
},
})
})
const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
db: DatabaseService,
event: typeof SessionEvent.Forked.Type,
@@ -187,7 +197,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.limit(1)
.get()
.pipe(Effect.orDie)
const copiedSeq = copied?.seq ?? 0
const copiedSeq = copied?.seq
const stored = yield* db
.insert(SessionTable)
@@ -237,9 +247,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
}
const usage = emptyUsage()
let cursor = -1
while (true) {
while (copiedSeq !== undefined) {
const rows = yield* db
.select()
.from(SessionMessageTable)
@@ -247,7 +256,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
and(
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
),
)
@@ -318,27 +327,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
}
for (const row of rows) {
const value = messageUsage(row)
if (value) addUsage(usage, value)
}
cursor = rows.at(-1)!.seq
}
yield* db
.update(SessionTable)
.set({
cost: usage.cost,
tokens_input: usage.tokens.input,
tokens_output: usage.tokens.output,
tokens_reasoning: usage.tokens.reasoning,
tokens_cache_read: usage.tokens.cache.read,
tokens_cache_write: usage.tokens.cache.write,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
})
function run(db: DatabaseService, event: MessageEvent) {
@@ -697,8 +688,19 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* applyUsage(db, event.data.sessionID, event.data)
}),
)
yield* events.project(SessionEvent.Step.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.data.cost !== undefined && event.data.tokens !== undefined)
yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens })
}),
)
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
@@ -790,6 +792,17 @@ const layer = Layer.effectDiscard(
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
}),
)
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
Stream.runForEach((event) => {
if (
event.type === SessionEvent.Step.Failed.type &&
(event.data.cost === undefined || event.data.tokens === undefined)
)
return Effect.void
return publishSessionUsage(db, events, event.data.sessionID)
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
+33 -3
View File
@@ -17,6 +17,7 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { Instructions } from "../../instructions/index"
import { InstructionBuiltIns } from "../../instructions/builtins"
@@ -50,6 +51,30 @@ import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
const context = tokens.input + tokens.cache.read + tokens.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return 0
return (
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000
)
}
/**
* Runs one durable coding-agent Session until it settles.
*
@@ -312,6 +337,11 @@ const layer = Layer.effect(
Effect.ensuring(serialized(publisher.flush())),
)
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
})
// Captures the end snapshot, diffs it against the step's start, and durably ends the
// assistant step.
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
@@ -328,8 +358,7 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,
tokens: settlement.tokens,
...stepUsage(settlement),
snapshot: endSnapshot,
files,
}),
@@ -452,7 +481,8 @@ const layer = Layer.effect(
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
if (stepFailure) yield* serialized(publisher.publishStepFailure())
if (stepFailure)
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (userDeclined) return yield* Effect.interrupt
+5 -1
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
@@ -94,13 +96,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -341,6 +344,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
cost: selected.cost,
}
}),
})
@@ -216,7 +216,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (replace || stepFailure === undefined) stepFailure = error
})
const publishStepFailure = Effect.fnUntraced(function* () {
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: number
readonly tokens: ReturnType<typeof tokens>
}) {
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
stepFailed = true
@@ -224,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
assistantMessageID,
error: stepFailure,
...usage,
})
})
@@ -409,12 +413,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
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
+1 -1
View File
@@ -41,7 +41,7 @@ Registrations are scoped:
## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
+3 -3
View File
@@ -12,7 +12,7 @@ import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
export const name = "apply_patch"
export const name = "patch"
export const Input = Schema.Struct({
patchText: Schema.String.annotate({
@@ -91,11 +91,11 @@ export const Plugin = {
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
})
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
@@ -3,7 +3,7 @@ export * as ExecuteTool from "./execute"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/llm"
import { Effect, Ref, Schema } from "effect"
import { definition, make, settle, type AnyTool } from "../tool/tool"
import { definition, make, settle, type AnyTool } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -40,11 +40,7 @@ export interface Registration {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly path: readonly [string, ...string[]]
}
interface CodeModeTools {
[name: string]: Tool.Definition<never> | CodeModeTools
readonly group?: string
}
export const create = (options: {
@@ -55,16 +51,33 @@ export const create = (options: {
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: CodeModeTools = Object.create(null)
for (const [key, registration] of options.registrations) {
const child = definition(registration.name, registration.tool)
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
run: (input) => invoke(key, registration, input),
run: (input) => invoke(name, registration, input),
})
addTool(tools, registration.path, value)
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
entries[path] = value
tools[namespace] = entries
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
@@ -99,15 +112,15 @@ export const create = (options: {
),
)
const result = yield* runtime(
(key, registration, input) =>
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const current = options.current(key)
const current = options.current(name)
if (!current || current.identity !== registration.identity)
return yield* Effect.fail(toolError(`Stale tool call: ${registration.path.join(".")}`))
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
const output = yield* settle(
current.tool,
{ type: "tool-call", id: context.toolCallID, name: registration.name, input },
{ type: "tool-call", id: context.toolCallID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
@@ -150,21 +163,6 @@ export const create = (options: {
})
}
function addTool(tools: CodeModeTools, path: readonly string[], value: Tool.Definition<never>) {
const [name, ...rest] = path
if (name === undefined) return
if (rest.length === 0) {
if (Object.hasOwn(tools, name)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
tools[name] = value
return
}
const current = tools[name]
if (Tool.isDefinition(current)) throw new TypeError(`Code Mode namespace conflict: ${path.join(".")}`)
const child: CodeModeTools = current ?? Object.create(null)
tools[name] = child
addTool(child, rest, value)
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }
+9 -11
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { CodeModeV2 } from "../code-mode"
import { Flag } from "../flag/flag"
import { MCP } from "../mcp"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -21,7 +21,6 @@ export const name = (server: string, tool: string) => `${group(server)}_${tool.r
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const codeMode = yield* CodeModeV2.Service
const tools = yield* Tools.Service
const events = yield* EventV2.Service
const permission = yield* PermissionV2.Service
@@ -106,14 +105,13 @@ export const layer = Layer.effectDiscard(
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
const register = codeMode.enabled
? codeMode.register((draft) => {
for (const [server, record] of groups)
for (const [tool, value] of Object.entries(record))
draft.add([group(server), tool.replace(/[^a-zA-Z0-9_-]/g, "_")], value)
})
: Effect.forEach(groups, ([group, record]) => tools.register(record, { group }), { discard: true })
yield* register.pipe(Scope.provide(next), Effect.orDie)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
@@ -130,5 +128,5 @@ export const layer = Layer.effectDiscard(
export const node = makeLocationNode({
name: "mcp-tools",
layer,
deps: [CodeModeV2.node, ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
})
+29 -11
View File
@@ -3,12 +3,13 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent"
import { CodeModeV2 } from "../code-mode"
import { Flag } from "../flag/flag"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
@@ -54,12 +55,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const registryLayer = Layer.effect(
Service,
Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = {
readonly identity: object
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly deferred: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -147,7 +150,7 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return
const reserved = entries.find((entry) => entry.key === "execute")
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
@@ -163,6 +166,9 @@ const registryLayer = Layer.effect(
registration: {
identity: {},
tool: entry.tool,
name: entry.name,
group: entry.group,
deferred: options?.deferred ?? false,
},
},
])
@@ -185,22 +191,34 @@ const registryLayer = Layer.effect(
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
// OpenAI/GPT models use patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (
wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
)
registrations.delete(name)
}
const execute = yield* codeMode.materialize({ permissions: input.permissions })
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
const execute =
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({
registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration,
})
: undefined
return {
definitions: [
...Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
],
settle: (input) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = registrations.get(input.call.name)
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}` },
@@ -226,11 +244,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [CodeModeV2.node, ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node],
})
+5 -7
View File
@@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () =>
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
})
const resolved = yield* aisdk.model(
{
...input,
headers: { "x-test": "header" },
body: { safety_setting: "strict" },
},
)
const resolved = yield* aisdk.model({
...input,
headers: { "x-test": "header" },
body: { safety_setting: "strict" },
})
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
LLM.request({ model: resolved, prompt: "Hello" }),
)
+8 -15
View File
@@ -191,7 +191,7 @@ describe("PluginV2", () => {
}),
)
it.effect("registers direct and Code Mode tools through separate plugin domains", () =>
it.effect("groups tool names and defers registrations from direct exposure", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@@ -205,17 +205,13 @@ describe("PluginV2", () => {
const plugin = define({
id: "grouped-tools",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"))
draft.add("look/up", tool("Lookup"), { group: "context 7" })
})
.pipe(Effect.orDie)
yield* ctx.codemode
.register((draft) => draft.add(["context_7", "search"], tool("Search")))
.pipe(Effect.orDie)
}),
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"))
draft.add("look/up", tool("Lookup"), { group: "context 7" })
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
})
.pipe(Effect.orDie),
})
yield* plugins.activate([{ plugin }])
@@ -225,9 +221,6 @@ describe("PluginV2", () => {
"context_7_look_up",
"execute",
])
yield* plugins.activate([])
expect((yield* registry.materialize({ model: testModel })).definitions).toEqual([])
}),
)
-2
View File
@@ -1,7 +1,6 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
import { CommandV2 } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -44,7 +43,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
AgentV2.node,
AISDK.node,
Catalog.node,
CodeModeV2.node,
CommandV2.node,
Integration.node,
PluginRuntime.node,
-3
View File
@@ -39,9 +39,6 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"),
},
codemode: overrides.codemode ?? {
register: () => Effect.die("unused codemode.register"),
},
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
+39 -3
View File
@@ -225,9 +225,17 @@ describe("SessionV2.create", () => {
promotedSeq: 2,
})
yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false })
yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "Parent changed" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false })
yield* session.prompt({
sessionID: forked.id,
prompt: PromptInput.Prompt.make({ text: "Child continues" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, forked.id)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -260,8 +268,25 @@ describe("SessionV2.create", () => {
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
const assistantMessageID = SessionMessage.ID.create()
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
yield* events.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: "build",
model,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID,
finish: "stop",
cost: 0.75,
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id })
const complete = yield* session.fork({ sessionID: parent.id })
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@@ -269,6 +294,13 @@ describe("SessionV2.create", () => {
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } })
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(beforeFirst.id)).toEqual([])
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(complete).toMatchObject({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
)
@@ -375,7 +407,11 @@ describe("SessionV2.create", () => {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false })
yield* session.prompt({
sessionID: created.id,
prompt: PromptInput.Prompt.make({ text: "Hello" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, created.id)
expect(
+59 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -41,12 +41,15 @@ const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
id: _,
type,
...data
} = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
} = encodeMessage(
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
)
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
@@ -67,6 +70,12 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const boundary = SessionMessage.ID.make("msg_boundary")
@@ -75,8 +84,24 @@ describe("SessionProjector", () => {
.insert(SessionMessageTable)
.values([
assistantRow(earlier, 0),
assistantRow(boundary, 1),
assistantRow(SessionMessage.ID.make("msg_later"), 2),
assistantRow(
boundary,
1,
{ created },
{
cost: 0.5,
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
),
assistantRow(
SessionMessage.ID.make("msg_later"),
2,
{ created },
{
cost: 0.75,
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
},
),
])
.run()
yield* db
@@ -106,6 +131,14 @@ describe("SessionProjector", () => {
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([earlier])
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
// A committed revert resets the context checkpoint so the next turn re-initializes.
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
@@ -534,12 +567,15 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const service = yield* EventV2.Service
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
const rows = yield* db
@@ -556,8 +592,25 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
sessionID,
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
}),
)
@@ -151,15 +151,39 @@ test("step finish records settlement without publishing step ended", async () =>
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" })))
await Effect.runPromise(
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
usage: {
nonCachedInputTokens: 8,
outputTokens: 3,
reasoningTokens: 1,
},
}),
),
)
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
await Effect.runPromise(publisher.publishStepFailure())
const settlement = publisher.stepSettlement()
expect(settlement).toMatchObject({
finish: "content-filter",
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
await Effect.runPromise(
publisher.publishStepFailure({
cost: 1.25,
tokens: settlement.tokens,
}),
)
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" },
cost: 1.25,
tokens: { input: 8, output: 2, reasoning: 1 },
})
expect(publisher.stepSettlement()).toBeUndefined()
})
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CodeModeV2 } from "@opencode-ai/core/code-mode"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
@@ -30,9 +28,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const registryLayer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, CodeModeV2.node]), [
[ToolOutputStore.node, outputStore],
])
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
@@ -57,30 +53,6 @@ const make = (permission?: string) => {
}
describe("ToolRegistry", () => {
it.effect("materializes nested Code Mode sources as execute", () =>
Effect.gen(function* () {
const codeMode = yield* CodeModeV2.Service
const service = yield* ToolRegistry.Service
yield* codeMode.register((draft) => draft.add(["opencode", "v2", "echo"], make()))
const definitions = yield* toolDefinitions(service)
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(definitions[0]?.description).toContain("tools.opencode.v2.echo")
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: {
type: "tool-call",
id: "call-code-mode",
name: "execute",
input: { code: 'return await tools.opencode.v2.echo({ text: "hello" })' },
},
}),
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -117,16 +89,16 @@ describe("ToolRegistry", () => {
read: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
patch: make("edit"),
})
const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service
.materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"])
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
}),
)
+35 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import {
LLMClient,
LLMError,
@@ -116,6 +116,28 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
[
{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } },
{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } },
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
).toBeCloseTo(0.0002926)
})
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionRunnerLLM.calculateCost(
[{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } },
),
).toBe(0)
})
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
@@ -1704,6 +1726,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "tool-calls",
cost: 0,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
content: [
{ type: "reasoning", text: "Think" },
@@ -3635,7 +3658,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "partial" }),
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
LLMEvent.stepFinish({
index: 0,
reason: "content-filter",
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
}),
LLMEvent.finish({ reason: "content-filter" }),
]
@@ -3646,9 +3673,15 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "error",
error: { type: "provider.content-filter" },
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
content: [{ type: "text", text: "Partial" }],
},
])
expect(yield* session.get(sessionID)).toMatchObject({
cost: 0,
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
})
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1")
}),
)
+5 -5
View File
@@ -26,7 +26,7 @@ const applyPatchToolNode = makeLocationNode({
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const sessionID = SessionV2.ID.make("ses_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
let failRemoveTarget: string | undefined
@@ -132,10 +132,10 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
// apply_patch is only materialized for OpenAI/GPT models.
// patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) =>
@@ -162,7 +162,7 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"apply_patch",
"patch",
])
const settled = yield* settleTool(
registry,
@@ -241,7 +241,7 @@ describe("ApplyPatchTool", () => {
),
model,
),
).toEqual({ type: "error", value: "apply_patch moves are not supported yet" })
).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([])
}),
@@ -1,12 +0,0 @@
import type { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "./tool.js"
export type Path = readonly [string, ...string[]]
export interface Draft {
add(path: Path, tool: AnyTool): void
}
export interface Domain {
readonly register: (source: (draft: Draft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
-2
View File
@@ -3,7 +3,6 @@ import type { AgentHooks } from "./agent.js"
import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js"
import type { Domain } from "./code-mode.js"
import type { EventHooks } from "./event.js"
import type { IntegrationHooks } from "./integration.js"
import type { PluginDomain } from "./plugin.js"
@@ -18,7 +17,6 @@ export interface PluginContext {
readonly aisdk: AISDKHooks
readonly catalog: CatalogHooks
readonly command: CommandHooks
readonly codemode: Domain
readonly event: EventHooks
readonly integration: IntegrationHooks
readonly plugin: PluginDomain
-1
View File
@@ -5,7 +5,6 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
export type { AISDKHooks } from "./aisdk.js"
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
export type { CommandDraft, CommandHooks } from "./command.js"
export * as CodeMode from "./code-mode.js"
export type { EventHooks } from "./event.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
+1
View File
@@ -246,6 +246,7 @@ export interface ToolExecuteAfterEvent {
export interface RegisterOptions {
readonly group?: string
readonly deferred?: boolean
}
export interface ToolDraft {
@@ -27,7 +27,6 @@ test.each([
expect(entrypoint.Skill).toBe(Skill)
expect(Object.keys(entrypoint).sort()).toEqual([
"Agent",
...(name === "effect" ? ["CodeMode"] : []),
"Command",
"Connection",
"Credential",
+23 -9
View File
@@ -30,6 +30,15 @@ export interface Source extends Schema.Schema.Type<typeof Source> {}
const Base = {
sessionID: SessionID,
}
const Tokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const PromptFields = {
...Base,
inputID: SessionMessage.ID,
@@ -84,6 +93,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const UsageUpdated = Event.ephemeral({
type: "session.usage.updated",
schema: {
...Base,
cost: Schema.Finite,
tokens: Tokens,
},
})
export type UsageUpdated = typeof UsageUpdated.Type
export const Deleted = Event.durable({
type: "session.deleted",
durable: {
@@ -224,15 +243,7 @@ export namespace Step {
assistantMessageID: SessionMessage.ID,
finish: FinishReason,
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
tokens: Tokens,
snapshot: Schema.String.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
},
@@ -246,6 +257,8 @@ export namespace Step {
...Base,
assistantMessageID: SessionMessage.ID,
error: SessionError.Error,
cost: Schema.Finite.pipe(optional),
tokens: Tokens.pipe(optional),
},
})
export type Failed = typeof Failed.Type
@@ -504,6 +517,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
UsageUpdated,
Deleted,
Forked,
PromptPromoted,
@@ -144,6 +144,8 @@ describe("public event manifest", () => {
expect(SessionEvent.DurableDefinitions).toEqual(
SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated)
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
})
+35
View File
@@ -277,6 +277,8 @@ import type {
V2CredentialUpdateErrors,
V2CredentialUpdateResponses,
V2DebugLocationErrors,
V2DebugLocationEvictErrors,
V2DebugLocationEvictResponses,
V2DebugLocationResponses,
V2EventSubscribeErrors,
V2EventSubscribeResponses,
@@ -8117,6 +8119,34 @@ export class Vcs2 extends HeyApiClient {
}
}
export class Location2 extends HeyApiClient {
/**
* Evict a loaded location
*
* Dispose the requested location's cached services so its next use boots them fresh.
*/
public evict<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).delete<
V2DebugLocationEvictResponses,
V2DebugLocationEvictErrors,
ThrowOnError
>({
url: "/api/debug/location",
...options,
...params,
})
}
}
export class Debug extends HeyApiClient {
/**
* List loaded locations
@@ -8129,6 +8159,11 @@ export class Debug extends HeyApiClient {
...options,
})
}
private _location?: Location2
get location2(): Location2 {
return (this._location ??= new Location2({ client: this.client }))
}
}
export class V2 extends HeyApiClient {
+168
View File
@@ -21,6 +21,7 @@ export type Event =
| EventSessionModelSelected
| EventSessionMoved
| EventSessionRenamed
| EventSessionUsageUpdated
| EventSessionForked
| EventSessionPromptPromoted
| EventSessionPromptAdmitted
@@ -895,6 +896,23 @@ export type GlobalEvent = {
title: string
}
}
| {
id: string
type: "session.usage.updated"
properties: {
sessionID: string
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
| {
id: string
type: "session.forked"
@@ -1042,6 +1060,16 @@ export type GlobalEvent = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
| {
@@ -3079,6 +3107,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionUsageUpdated
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
@@ -3982,6 +4011,16 @@ export type SyncEventSessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
}
@@ -5088,6 +5127,16 @@ export type SessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
@@ -5955,6 +6004,29 @@ export type MessagePartRemoved = {
}
}
export type SessionUsageUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.usage.updated"
location?: LocationRef
data: {
sessionID: string
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
export type SessionTextDelta = {
id: string
created: number
@@ -7010,6 +7082,24 @@ export type EventSessionRenamed = {
}
}
export type EventSessionUsageUpdated = {
id: string
type: "session.usage.updated"
properties: {
sessionID: string
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
export type EventSessionForked = {
id: string
type: "session.forked"
@@ -7171,6 +7261,16 @@ export type EventSessionStepFailed = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
@@ -8525,6 +8625,7 @@ export type V2EventV2 =
| SessionModelSelectedV2
| SessionMovedV2
| SessionRenamedV2
| SessionUsageUpdatedV2
| SessionDeletedV2
| SessionForkedV2
| SessionPromptPromotedV2
@@ -9272,6 +9373,16 @@ export type SessionStepFailedV2 = {
sessionID: string
assistantMessageID: string
error: SessionStructuredError
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
@@ -9996,6 +10107,29 @@ export type MessagePartRemovedV2 = {
}
}
export type SessionUsageUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.usage.updated"
location?: LocationRefV2
data: {
sessionID: string
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
}
export type SessionTextDeltaV2 = {
id: string
created: number
@@ -18549,6 +18683,40 @@ export type V2VcsDiffResponses = {
export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses]
export type V2DebugLocationEvictData = {
body?: never
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/debug/location"
}
export type V2DebugLocationEvictErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2DebugLocationEvictError = V2DebugLocationEvictErrors[keyof V2DebugLocationEvictErrors]
export type V2DebugLocationEvictResponses = {
/**
* <No Content>
*/
204: void
}
export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses]
export type V2DebugLocationData = {
body?: never
path?: never
@@ -536,6 +536,7 @@ export function getToolInfo(
title: i18n.t("ui.messagePart.title.write"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
case "patch":
case "apply_patch":
return {
icon: "code-lines",
@@ -728,7 +729,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -1449,7 +1450,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
}
export function getTool(name: string) {
return state[name]?.render
return state[name === "apply_patch" ? "patch" : name]?.render
}
export const ToolRegistry = {
@@ -2272,7 +2273,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "apply_patch",
name: "patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
@@ -5,7 +5,7 @@ const docs = `### Overview
Tool call failure summary styled like a tool trigger.
### API
- Required: \`tool\` (tool id, e.g. apply_patch, bash)
- Required: \`tool\` (tool id, e.g. patch, bash)
- Required: \`error\` (error string)
### Behavior
@@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger.
const samples = [
{
tool: "apply_patch",
tool: "patch",
error:
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
"patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
},
{
tool: "bash",
@@ -62,13 +62,13 @@ export default {
},
},
args: {
tool: "apply_patch",
tool: "patch",
error: samples[0].error,
},
argTypes: {
tool: {
control: "select",
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
},
error: {
control: "text",
@@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}
+14 -14
View File
@@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import type { SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner"
@@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { lastAssistantWithUsage } from "../../util/session"
registerOpencodeSpinner()
@@ -276,18 +277,20 @@ export function Prompt(props: PromptProps) {
const usage = createMemo(() => {
if (!props.sessionID) return
const session = sync.session.get(props.sessionID)
const msg = sync.data.message[props.sessionID] ?? []
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
const session = data.session.get(props.sessionID)
if (!session) return
const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = session?.cost ?? 0
const cost = session.cost
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
@@ -337,7 +340,8 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize agent/model/variant from the durable V2 Session state.
// Initialize the agent from the durable V2 Session state. The model context
// follows durable Session model changes while preserving unsent local picks.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
@@ -346,10 +350,6 @@ export function Prompt(props: PromptProps) {
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
@@ -1624,11 +1624,11 @@ export function Prompt(props: PromptProps) {
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
+76 -4
View File
@@ -110,6 +110,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const sessionRefreshGeneration = new Map<string, number>()
const sessionRefreshApplied = new Map<string, number>()
const sessionUsage = new Map<string, { generation: number; cost: number; tokens: SessionV2Info["tokens"] }>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
@@ -119,6 +122,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "status", sessionID, status)
}
function nextSessionRefresh(sessionID: string) {
const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1
sessionRefreshGeneration.set(sessionID, generation)
return generation
}
function applySessionRefresh(sessionID: string, generation: number) {
if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false
sessionRefreshApplied.set(sessionID, generation)
return true
}
function updateSessionUsage(sessionID: string, cost: number, tokens: SessionV2Info["tokens"]) {
sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens })
if (!store.session.info[sessionID]) return
setStore("session", "info", sessionID, { cost, tokens })
}
const message = {
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
setStore(
@@ -222,6 +243,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function removeSession(sessionID: string) {
sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID))
sessionUsage.delete(sessionID)
messageIndex.delete(sessionID)
setStore(
"session",
@@ -250,6 +273,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.deleted":
removeSession(event.data.sessionID)
break
case "session.usage.updated":
updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens)
break
case "catalog.updated":
void Promise.all([
result.location.model.refresh(event.location),
@@ -420,7 +446,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.step.ended":
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@@ -432,6 +458,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
break
}
case "session.step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
@@ -440,6 +467,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.finish = "error"
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
}
})
break
case "session.text.started":
@@ -639,8 +670,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.revert.committed":
if (store.session.info[event.data.sessionID])
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
setStore(
"session",
"input",
@@ -811,7 +843,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.compaction[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
const generation = nextSessionRefresh(sessionID)
const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0
const info = mutable(await sdk.api.session.get({ sessionID }))
if (!applySessionRefresh(sessionID, generation)) return
const usage = sessionUsage.get(sessionID)
setStore(
"session",
"info",
sessionID,
usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info,
)
registerSession(sessionID)
},
message: {
@@ -994,6 +1036,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
const generation = new Map(sessionRefreshApplied)
const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation]))
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
@@ -1007,11 +1051,39 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = mutable(session)
for (const session of response.data) {
if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue
const usage = sessionUsage.get(session.id)
draft[session.id] = mutable(
usage && usage.generation !== (usageGeneration.get(session.id) ?? 0)
? { ...session, cost: usage.cost, tokens: usage.tokens }
: session,
)
}
}),
)
for (const session of response.data) registerSession(session.id)
}),
sdk.api.permission.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => {
const permissions = mutable(response.data).reduce<Record<string, PermissionV2Request[]>>(
(result, request) => ({
...result,
[request.sessionID]: [...(result[request.sessionID] ?? []), request],
}),
{},
)
setStore("session", "permission", reconcile(permissions))
}),
sdk.api.form.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => {
const forms = mutable(response.data).reduce<Record<string, FormInfo[]>>(
(result, form) => ({
...result,
[form.sessionID]: [...(result[form.sessionID] ?? []), form],
}),
{},
)
setStore("session", "form", reconcile(forms))
}),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
+21
View File
@@ -245,6 +245,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})
let syncedSessionModel: string | undefined
createEffect(() => {
if (route.data.type !== "session") {
syncedSessionModel = undefined
return
}
const session = data.session.get(route.data.sessionID)
const selected = session?.model
const a = agent.current()
if (!selected || !a) return
const model = { providerID: selected.providerID, modelID: selected.id }
if (!isModelValid(model)) return
const fingerprint = [session.id, a.id, selected.providerID, selected.id, selected.variant ?? "default"].join(":")
if (fingerprint === syncedSessionModel) return
syncedSessionModel = fingerprint
batch(() => {
setModelStore("model", a.id, model)
setModelStore("variant", `${selected.providerID}/${selected.id}`, selected.variant ?? "default")
})
})
return {
current: currentModel,
get ready() {
@@ -1,7 +1,8 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
import { useData } from "../../context/data"
import { lastAssistantWithUsage } from "../../util/session"
const id = "internal:sidebar-context"
@@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
if (!last) {
return {
tokens: 0,
@@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session()?.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
+3 -3
View File
@@ -1908,7 +1908,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "execute"}>
<Execute {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
<Match when={display() === "patch"}>
<ApplyPatch {...toolprops} />
</Match>
<Match when={display() === "todowrite"}>
@@ -2737,7 +2737,7 @@ const toolDisplays = new Set([
"edit",
"subagent",
"execute",
"apply_patch",
"patch",
"todowrite",
"question",
"skill",
@@ -2746,7 +2746,7 @@ const toolDisplays = new Set([
export function toolDisplay(tool: string) {
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
return toolDisplays.has(normalized) ? normalized : "generic"
}
@@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { lastAssistantWithUsage } from "../../util/session"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -22,17 +23,15 @@ export function SubagentFooter() {
const usage = createMemo(() => {
const current = session()
if (!current) return
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
if (!last) return
const tokens =
current.tokens.input +
current.tokens.output +
current.tokens.reasoning +
current.tokens.cache.read +
current.tokens.cache.write
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = current.cost
@@ -83,10 +82,10 @@ export function SubagentFooter() {
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
+14
View File
@@ -1,3 +1,17 @@
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessage>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
return messages.findLast(
(
message,
index,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
)
}
+461 -2
View File
@@ -108,6 +108,309 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("applies absolute usage events without losing full session updates", async () => {
const events = createEventStream()
const sessionID = "ses_usage_refresh"
let resolveSessions!: (response: Response) => void
const resolveSession: Array<(response: Response) => void> = []
let sessionsRequested = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
sessionsRequested = true
return new Promise<Response>((resolve) => {
resolveSessions = resolve
})
}
if (url.pathname === `/api/session/${sessionID}`) {
return new Promise<Response>((resolve) => {
resolveSession.push(resolve)
})
}
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => sessionsRequested)
emitEvent(events, {
id: "evt_usage_2",
created: 2,
type: "session.usage.updated",
data: {
sessionID,
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
const initialRefresh = data.session.refresh(sessionID)
await wait(() => resolveSession.length === 1)
resolveSessions(
json({
data: [
{
id: sessionID,
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Stale usage",
location: { directory },
},
],
cursor: {},
}),
)
resolveSession[0](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Current usage",
location: { directory },
},
}),
)
await initialRefresh
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.get(sessionID)?.tokens).toEqual({
input: 5,
output: 2,
reasoning: 1,
cache: { read: 1, write: 1 },
})
const fullRefresh = data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_usage_3",
created: 3,
type: "session.usage.updated",
data: {
sessionID,
cost: 1,
tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 1)
resolveSession[1](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.75,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Older usage",
location: { directory },
},
}),
)
await fullRefresh
await Bun.sleep(20)
expect(data.session.get(sessionID)?.cost).toBe(1)
expect(data.session.get(sessionID)?.title).toBe("Older usage")
emitEvent(events, {
id: "evt_usage_6",
created: 6,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
emitEvent(events, {
id: "evt_usage_7",
created: 7,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 1.25)
expect(data.session.get(sessionID)?.title).toBe("Older usage")
emitEvent(events, {
id: "evt_usage_8",
created: 8,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.5,
tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
emitEvent(events, {
id: "evt_usage_deleted",
created: 9,
type: "session.deleted",
durable: durable(sessionID, 9),
data: { sessionID },
})
await Bun.sleep(20)
expect(data.session.get(sessionID)).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("truncates committed revert messages without changing lifetime usage", async () => {
const events = createEventStream()
const sessionID = "ses_revert_usage"
let cost = 0
let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname !== `/api/session/${sessionID}`) return
return json({
data: {
id: sessionID,
projectID: "proj_test",
cost,
tokens,
time: { created: 0, updated: 0 },
title: "Revert usage",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_revert_boundary_started",
created: 1,
type: "session.step.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.5
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_boundary_ended",
created: 2,
type: "session.step.ended",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
finish: "stop",
cost: 0.5,
tokens,
},
})
emitEvent(events, {
id: "evt_revert_boundary_usage",
created: 2,
type: "session.usage.updated",
data: { sessionID, cost, tokens },
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
emitEvent(events, {
id: "evt_revert_later_started",
created: 3,
type: "session.step.started",
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.75
tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_later_ended",
created: 4,
type: "session.step.ended",
durable: durable(sessionID, 4),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
finish: "stop",
cost: 0.25,
tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
emitEvent(events, {
id: "evt_revert_later_usage",
created: 4,
type: "session.usage.updated",
data: { sessionID, cost, tokens },
})
await wait(() => data.session.get(sessionID)?.cost === 0.75)
emitEvent(events, {
id: "evt_revert_staged",
created: 5,
type: "session.revert.staged",
durable: durable(sessionID, 5),
data: { sessionID, revert: { messageID: "msg_revert_later" } },
})
await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later")
emitEvent(events, {
id: "evt_revert_committed",
created: 6,
type: "session.revert.committed",
durable: durable(sessionID, 6),
data: { sessionID, to: "msg_revert_later" },
})
await wait(() => data.session.message.ids(sessionID).length === 1)
expect(data.session.get(sessionID)?.cost).toBe(0.75)
expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"])
expect(data.session.get(sessionID)?.revert).toBeUndefined()
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
} finally {
app.renderer.destroy()
}
})
test("updates session location when moved", async () => {
const events = createEventStream()
const destination = "/tmp/opencode-moved"
@@ -517,8 +820,35 @@ test("connectedOnce is false until first connect and persists across disconnect"
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
let settled = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
if (url.pathname === "/api/session/session-live")
return json({
data: {
id: "session-live",
projectID: "proj_test",
cost: settled ? 0.75 : 0,
tokens: settled
? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }
: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Live session",
location: { directory },
},
})
if (url.pathname === "/api/session/session-failed")
return json({
data: {
id: "session-failed",
projectID: "proj_test",
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
time: { created: 0, updated: 0 },
title: "Failed session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
let rows!: SessionRow[]
@@ -546,7 +876,9 @@ test("tracks session status from active sessions and execution events", async ()
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
await data.session.refresh("session-live")
settled = true
emitEvent(events, {
id: "evt_execution_started",
created: 0,
@@ -577,15 +909,30 @@ test("tracks session status from active sessions and execution events", async ()
sessionID: "session-live",
assistantMessageID: "message-live",
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
emitEvent(events, {
id: "evt_step_usage",
created: 0,
type: "session.usage.updated",
data: {
sessionID: "session-live",
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
await wait(() => {
const assistant = data.session.message.get("session-live", "message-live")
return assistant?.type === "assistant" && assistant.finish === "stop"
})
await wait(() => data.session.get("session-live")?.cost === 0.75)
expect(data.session.status("session-live")).toBe("running")
expect(data.session.get("session-live")).toMatchObject({
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
emitEvent(events, {
id: "evt_execution_succeeded",
@@ -596,6 +943,7 @@ test("tracks session status from active sessions and execution events", async ()
})
await wait(() => data.session.status("session-live") === "idle")
await data.session.refresh("session-failed")
emitEvent(events, {
id: "evt_failed_execution_started",
created: 0,
@@ -626,6 +974,18 @@ test("tracks session status from active sessions and execution events", async ()
sessionID: "session-failed",
assistantMessageID: "message-failed",
error: { type: "provider.content-filter", message: "Provider blocked the response" },
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
})
emitEvent(events, {
id: "evt_failed_step_usage",
created: 0,
type: "session.usage.updated",
data: {
sessionID: "session-failed",
cost: 0.25,
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
})
await wait(() => {
@@ -636,6 +996,13 @@ test("tracks session status from active sessions and execution events", async ()
assistant.error?.type === "provider.content-filter"
)
})
await wait(() => data.session.get("session-failed")?.cost === 0.25)
expect(data.session.get("session-failed")?.tokens).toEqual({
input: 5,
output: 1,
reasoning: 1,
cache: { read: 1, write: 0 },
})
expect(data.session.status("session-failed")).toBe("running")
emitEvent(events, {
@@ -1154,6 +1521,52 @@ test("adds and dismisses permission requests from live events", async () => {
}
})
test("reconciles all pending permission requests when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] },
{ id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] },
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/permission/request") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old")
expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep")
requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }]
events.disconnect()
await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new")
expect(data.session.permission.list("ses_old")).toBeUndefined()
expect(data.session.permission.list("ses_keep")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("adds, dismisses, and refreshes form requests", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
@@ -1224,6 +1637,52 @@ test("adds, dismisses, and refreshes form requests", async () => {
}
})
test("reconciles all pending form requests when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] },
{ id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" },
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }]
events.disconnect()
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
expect(data.session.form.list("ses_old")).toBeUndefined()
expect(data.session.form.list("ses_keep")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
@@ -0,0 +1,146 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../src/context/args"
import { DataProvider } from "../../../src/context/data"
import { KVProvider } from "../../../src/context/kv"
import { LocalProvider, useLocal } from "../../../src/context/local"
import { PermissionProvider } from "../../../src/context/permission"
import { ProjectProvider } from "../../../src/context/project"
import { RouteProvider } from "../../../src/context/route"
import { SDKProvider } from "../../../src/context/sdk"
import { SyncProvider } from "../../../src/context/sync"
import { ThemeProvider } from "../../../src/context/theme"
import { TuiConfigProvider } from "../../../src/config"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
test("follows durable model switches for the active session", async () => {
await using tmp = await tmpdir()
const state = path.join(tmp.path, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")
const events = createEventStream()
const session = {
id: "ses_test",
projectID: "proj_test",
agent: "build",
model: { providerID: "repro", id: "alpha" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Test session",
location: { directory },
}
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/model")
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{ providerID: "repro", id: "alpha", name: "Alpha" },
{ providerID: "repro", id: "beta", name: "Beta" },
{ providerID: "repro", id: "gamma", name: "Gamma", variants: [{ id: "high" }] },
],
})
if (url.pathname === "/api/agent")
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [{ id: "build", mode: "primary", hidden: false }],
})
return undefined
}, events)
let local!: ReturnType<typeof useLocal>
let mounted!: () => void
const ready = new Promise<void>((resolve) => {
mounted = resolve
})
function Probe() {
local = useLocal()
onMount(mounted)
return <text>{local.model.current()?.modelID}</text>
}
const app = await testRender(() => (
<TestTuiContexts directory={tmp.path} paths={{ state }}>
<ArgsProvider>
<KVProvider>
<ToastProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: session.id }}>
<TuiConfigProvider config={createTuiResolvedConfig()}>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode="dark">
<LocalProvider>
<Probe />
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</TestTuiContexts>
))
try {
await ready
await wait(() => local.model.ready && local.model.current()?.modelID === "alpha")
local.model.set({ providerID: "repro", modelID: "beta" })
events.emit({
id: "evt_model_alpha",
created: 1,
type: "session.model.selected",
durable: { aggregateID: session.id, seq: 1, version: 1 },
location: { directory },
data: {
sessionID: session.id,
model: { providerID: "repro", id: "alpha" },
},
})
await Bun.sleep(20)
expect(local.model.current()?.modelID).toBe("beta")
events.emit({
id: "evt_model_gamma",
created: 2,
type: "session.model.selected",
durable: { aggregateID: session.id, seq: 2, version: 1 },
location: { directory },
data: {
sessionID: session.id,
model: { providerID: "repro", id: "gamma", variant: "high" },
},
})
await wait(() => local.model.current()?.modelID === "gamma")
expect(local.model.current()).toEqual({ providerID: "repro", modelID: "gamma" })
expect(local.model.variant.current()).toBe("high")
} finally {
app.renderer.destroy()
}
})
+4
View File
@@ -101,6 +101,10 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
+20 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { isDefaultTitle } from "../../src/util/session"
import type { SessionMessage } from "@opencode-ai/sdk/v2"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
@@ -7,4 +8,22 @@ describe("util.session", () => {
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("tracks usage across undo and redo boundaries", () => {
const assistant = (id: string, input: number): SessionMessage => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
})
})