chore(observability): merge v2

This commit is contained in:
starptech 2026-07-09 17:08:08 +01:00
commit 9307833c5c
261 changed files with 727 additions and 4827 deletions

File diff suppressed because one or more lines are too long

View file

@ -526,7 +526,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
@ -559,7 +558,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
@ -577,7 +575,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",

View file

@ -182,12 +182,12 @@ describe("PermissionV2", () => {
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }]
agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
}),
)
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({
expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
effect: "allow",
})

View file

@ -101,7 +101,7 @@ import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, References, Schema, Stream, Tracer } from "effect"
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
@ -219,6 +219,20 @@ test("does not apply an ineligible tier without base pricing", () => {
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permissionFail = Tool.make({
description: "Reject a permission",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
new ToolFailure({
message: "Permission denied: edit",
error: new PermissionV2.BlockedError({
rules: [],
permission: "edit",
resources: ["src/index.ts"],
}),
}),
})
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
@ -560,6 +574,22 @@ const recordedEventTypes = (id: SessionV2.ID) =>
)
})
const recordedToolInputEnds = (id: SessionV2.ID, callID: string) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
return (yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, id),
eq(EventTable.type, EventV2.versionedType(SessionEvent.Tool.Input.Ended.type, 1)),
),
)
.all()
.pipe(Effect.orDie)).filter((event) => event.data.callID === callID)
})
const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: SessionMessage.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@ -3247,22 +3277,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
permissionfail: Tool.make({
description: "Reject a permission",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
new ToolFailure({
message: "Permission denied: edit",
error: new PermissionV2.BlockedError({
rules: [],
permission: "edit",
resources: ["src/index.ts"],
}),
}),
}),
})
yield* registry.register({ permissionfail: permissionFail })
yield* admit(session, "Reject permission")
responses = [
reply.tool("call-permission", "permissionfail", {}),
@ -3301,6 +3316,90 @@ describe("SessionRunnerLLM", () => {
}),
)
const rejectPermissionWhileToolInputStreams = (lateEvent: LLMEvent) =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
const releaseLateEvent = yield* Deferred.make<void>()
yield* registry.register({ permissionfail: permissionFail })
const events = yield* EventV2.Service
const permissionFailed = yield* events
.subscribe(SessionEvent.Tool.Failed)
.pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-permission"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Reject permission while another tool input streams")
responseStream = Stream.concat(
Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-streaming", name: "echo" }),
LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }),
]),
Stream.fromEffect(Deferred.await(releaseLateEvent)).pipe(Stream.flatMap(() => Stream.make(lateEvent))),
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Fiber.join(permissionFailed).pipe(Effect.timeout("1 second"))
yield* Effect.yieldNow
const inputEndsBeforeRelease = yield* recordedToolInputEnds(sessionID, "call-streaming")
yield* Deferred.succeed(releaseLateEvent, undefined)
const exit = yield* Fiber.await(run)
return {
exit,
inputEndsBeforeRelease,
context: yield* session.context(sessionID),
inputEnds: yield* recordedToolInputEnds(sessionID, "call-streaming"),
}
})
for (const testCase of [
{
name: "does not end concurrent tool input when permission is rejected",
event: LLMEvent.toolInputDelta({
id: "call-streaming",
name: "echo",
text: '{"text":"still streaming"}',
}),
defect: "Tool input delta after end: call-streaming",
},
{
name: "does not duplicate concurrent tool input end when permission is rejected",
event: LLMEvent.toolInputEnd({ id: "call-streaming", name: "echo" }),
defect: "Duplicate tool input end: call-streaming",
},
]) {
it.effect(testCase.name, () =>
Effect.gen(function* () {
const result = yield* rejectPermissionWhileToolInputStreams(testCase.event)
expect(result.inputEndsBeforeRelease).toHaveLength(0)
expect(Exit.isFailure(result.exit)).toBe(true)
if (Exit.isFailure(result.exit)) {
expect(Cause.pretty(result.exit.cause)).not.toContain(testCase.defect)
expect(Cause.hasDies(result.exit.cause)).toBe(false)
}
expect(result.context).toMatchObject([
{ type: "user" },
{
type: "assistant",
error: { type: "permission.rejected", message: "Permission denied: edit" },
content: [
{
type: "tool",
id: "call-streaming",
state: { status: "error", error: { type: "aborted", message: "Tool execution interrupted" } },
},
{ type: "tool", id: "call-permission", state: { status: "error" } },
],
},
])
expect(result.inputEnds).toHaveLength(1)
}),
)
}
it.effect("interrupts runner continuation when a question is cancelled", () =>
Effect.gen(function* () {
const session = yield* setup

View file

@ -1,94 +0,0 @@
import { describe, expect } from "bun:test"
import { asc } from "drizzle-orm"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node])))
const sessionID = SessionV2.ID.make("ses_todo_test")
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todo",
directory: "/project",
title: "todo",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
describe("SessionTodo", () => {
it.effect("replaces persisted todos in order and publishes updates", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const todos = yield* SessionTodo.Service
const published = new Array<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === SessionTodo.Event.Updated.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* todos.update({
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
})
expect(yield* todos.get(sessionID)).toEqual([
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
])
expect(
(yield* db.select().from(TodoTable).orderBy(asc(TodoTable.position)).all().pipe(Effect.orDie)).map((row) => ({
content: row.content,
position: row.position,
})),
).toEqual([
{ content: "second", position: 0 },
{ content: "first", position: 1 },
])
yield* todos.update({ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] })
expect(yield* todos.get(sessionID)).toEqual([{ content: "replacement", status: "completed", priority: "medium" }])
yield* todos.update({ sessionID, todos: [] })
expect(yield* todos.get(sessionID)).toEqual([])
expect(published.map((event) => event.data)).toEqual([
{
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
},
{ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] },
{ sessionID, todos: [] },
])
}),
)
})

View file

@ -24,7 +24,6 @@ import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Pty } from "@opencode-ai/schema/pty"
import { Reference } from "@opencode-ai/schema/reference"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -46,7 +45,6 @@ test("Core reuses the canonical shared schemas", async () => {
coreReference,
coreSessionInput,
coreSessionMessage,
coreSessionTodo,
coreSkill,
coreV2Schema,
coreSchema,
@ -67,7 +65,6 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/reference"),
import("@opencode-ai/core/session/input"),
import("@opencode-ai/core/session/message"),
import("@opencode-ai/core/session/todo"),
import("@opencode-ai/core/skill"),
import("@opencode-ai/core/v2-schema"),
import("@opencode-ai/core/schema"),
@ -160,8 +157,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.Assistant, SessionMessage.Assistant],
[coreSessionMessage.Compaction, SessionMessage.Compaction],
[coreSessionMessage.Info, SessionMessage.Info],
[coreSessionTodo.Info, SessionTodo.Info],
[coreSessionTodo.Event, SessionTodo.Event],
[coreSkill.DirectorySource, Skill.DirectorySource],
[coreSkill.UrlSource, Skill.UrlSource],
[coreSkill.EmbeddedSource, Skill.EmbeddedSource],

View file

@ -1,142 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const todoWriteToolNode = makeLocationNode({
name: "test/todowrite-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
})
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionTodo.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
todoWriteToolNode,
]),
[
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
)
const setup = Effect.gen(function* () {
assertions.length = 0
deny = false
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todowrite",
directory: "/project",
title: "todowrite",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
})
describe("TodoWriteTool", () => {
it.effect("registers, approves the wildcard resource, persists todos, and returns typed output", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
const todoList: ReadonlyArray<SessionTodo.Info> = [
{ content: "Implement slice", status: "in_progress", priority: "high" },
]
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* settleTool(registry, call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: {
structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
},
})
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
it.effect("does not update persisted todos when permission is denied", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
type: "error",
value: "Unable to update todos",
})
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})