refactor(schema): apply session review decisions (#35793)

This commit is contained in:
Kit Langton 2026-07-07 22:10:11 -04:00 committed by GitHub
commit ed6ad272ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 4239 additions and 3174 deletions

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@ -298,13 +299,31 @@ describe("CatalogV2", () => {
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(1),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(10),
output: Money.USDPerMillionTokens.make(10),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
})

View file

@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -87,7 +88,7 @@ Review files`,
name: "review",
template: "Review files",
description: "File review",
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.cost).toEqual([
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
tier: undefined,
},
])
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants?.map((variant) => variant.id)).toEqual([

View file

@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
import path from "path"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -26,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
@ -42,6 +44,256 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => {
test("migrates pre-launch V2 state in place", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
)
yield* db.run(
sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
)
const messages = [
["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }],
[
"msg_shell",
"shell",
{
shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" },
output: { output: "/tmp", cursor: 4, size: 4, truncated: false },
time: { created: 2, completed: 3 },
},
],
[
"msg_assistant",
"assistant",
{
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_old",
name: "read",
provider: "removed",
state: { status: "pending", input: '{"path":"README.md"}', title: "removed" },
time: { created: 3 },
},
],
time: { created: 3 },
},
],
[
"msg_failed",
"compaction",
{
status: "failed",
reason: "manual",
summary: "removed",
recent: "removed",
time: { created: 4 },
},
],
[
"msg_queued",
"compaction",
{ status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } },
],
[
"msg_synthetic",
"synthetic",
{ sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } },
],
[
"msg_running",
"compaction",
{ status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } },
],
[
"msg_completed",
"compaction",
{ status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } },
],
] as const
for (const [id, type, data] of messages)
yield* db.run(
sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`,
)
yield* db.run(
sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`,
)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`)
yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`)
const events = [
["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }],
["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }],
["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }],
["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }],
[
"evt_revert",
5,
105,
"session.revert.staged.1",
{
sessionID: "ses_test",
revert: {
messageID: "msg_skill",
snapshot: "tree",
diff: "removed",
files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
},
},
],
[
"evt_skill_current",
6,
106,
"session.skill.activated.2",
{ sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
],
] as const
for (const [id, seq, created, type, data] of events)
yield* db.run(
sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`,
)
yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration])
const rows = yield* db.all<{
id: string
type: string
seq: number
time_created: number
time_updated: number
data: string
}>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`)
for (const row of rows)
Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type })
expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true)
expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([
[
"msg_assistant",
expect.objectContaining({
content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })],
}),
],
["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })],
[
"msg_failed",
{
time: { created: 4 },
status: "failed",
reason: "manual",
error: {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
},
],
["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })],
["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })],
["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }],
["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }],
])
expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({
id: "msg_queued",
session_id: "ses_test",
type: "compaction",
prompt: null,
delivery: null,
admitted_seq: 4,
promoted_seq: null,
time_created: 5,
})
const migratedEvents = yield* db.all<{
id: string
aggregate_id: string
seq: number
created: number
type: string
data: string
}>(sql`SELECT * FROM event ORDER BY seq`)
expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([
{
id: "evt_skill",
aggregate_id: "ses_test",
seq: 1,
created: 101,
type: "session.skill.activated.1",
data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" },
},
{
id: "evt_started",
aggregate_id: "ses_test",
seq: 2,
created: 102,
type: "session.compaction.started.1",
data: { sessionID: "ses_test", reason: "auto", recent: "" },
},
{
id: "evt_failed",
aggregate_id: "ses_test",
seq: 4,
created: 104,
type: "session.compaction.failed.1",
data: {
sessionID: "ses_test",
reason: "auto",
error: {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
},
},
{
id: "evt_revert",
aggregate_id: "ses_test",
seq: 5,
created: 105,
type: "session.revert.staged.1",
data: {
sessionID: "ses_test",
revert: {
messageID: "msg_skill",
snapshot: "tree",
files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
},
},
},
{
id: "evt_skill_current",
aggregate_id: "ses_test",
seq: 6,
created: 106,
type: "session.skill.activated.1",
data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
},
])
expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({
aggregate_id: "ses_test",
seq: 9,
owner_id: "owner",
})
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
session_id: "ses_test",
baseline: "baseline",
snapshot: '{"source":"value"}',
baseline_seq: 7,
})
}),
)
})
test("resets incompatible V2 Session event history", async () => {
await run(
Effect.gen(function* () {

View file

@ -146,7 +146,7 @@ describe("Git trees", () => {
RelativePath.make("scope/tracked.txt"),
])
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
expect(diffs.map((item) => [item.path, item.status])).toEqual([
expect(diffs.map((item) => [item.file, item.status])).toEqual([
[RelativePath.make("scope/added.txt"), "added"],
[RelativePath.make("scope/tracked.txt"), "modified"],
])
@ -154,7 +154,7 @@ describe("Git trees", () => {
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
yield* git.tree.restore({ repository, files })
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")

View file

@ -3,6 +3,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -356,7 +357,7 @@ describe("LocationServiceMap", () => {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
@ -405,7 +406,7 @@ describe("LocationServiceMap", () => {
providerID: ProviderV2.ID.make("aliased"),
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,

View file

@ -1,5 +1,6 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => {
temperature: true,
tool_call: true,
cost: {
input: 2.5,
output: 15,
input: Money.USDPerMillionTokens.make(2.5),
output: Money.USDPerMillionTokens.make(15),
tiers: [
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache_read: 0.25,
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(18),
cache_read: Money.USDPerMillionTokens.make(0.25),
},
],
context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 },
context_over_200k: {
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache_read: Money.USDPerMillionTokens.make(0.5),
},
},
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
experimental: {
modes: {
fast: {
cost: { input: 5, output: 30, cache_read: 0.5 },
cost: {
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(30),
cache_read: Money.USDPerMillionTokens.make(0.5),
},
provider: {
headers: { "x-mode": "fast" },
body: { service_tier: "priority" },
@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => {
variants: [],
})
expect(fast?.cost).toEqual([
{ input: 5, output: 30, cache: { read: 0.5, write: 0 } },
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(30),
cache: {
read: Money.USDPerMillionTokens.make(0.5),
write: Money.USDPerMillionTokens.zero,
},
},
{
tier: { type: "context", size: 272_000 },
input: 3,
output: 18,
cache: { read: 0.25, write: 0 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(18),
cache: {
read: Money.USDPerMillionTokens.make(0.25),
write: Money.USDPerMillionTokens.zero,
},
},
{
tier: { type: "context", size: 200_000 },
input: 5,
output: 22.5,
cache: { read: 0.5, write: 0 },
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache: {
read: Money.USDPerMillionTokens.make(0.5),
write: Money.USDPerMillionTokens.zero,
},
},
])
}),

View file

@ -1,4 +1,5 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.zero,
},
},
]
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})

View file

@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@ -65,7 +66,16 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
output: Money.USDPerMillionTokens.make(output),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () =>

View file

@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => {
Effect.provide(NodeFileSystem.layer),
)
const skills = yield* skill.list()
const report = skills.find((item) => item.name === "report")
const report = skills.find((item) => item.id === "report")
expect(skills).toContainEqual(
expect.objectContaining({
name: "opencode",
id: "opencode",
name: "OpenCode",
description: expect.stringContaining("any question about OpenCode itself"),
}),
)
expect(skills).toContainEqual(
expect.objectContaining({
name: "report",
id: "report",
name: "Report",
description: expect.stringContaining("opencode issue"),
}),
)

View file

@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@ -104,13 +105,10 @@ describe("SessionV2.compact", () => {
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({
id: first.id,
})
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
}),
)
})

View file

@ -141,7 +141,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
expect(
yield* compaction.compactManual({
session,
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toBe(true)
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1)

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@ -210,7 +211,7 @@ describe("SessionV2.create", () => {
expect(forked.parentID).toBeUndefined()
expect(forkContext).toMatchObject([
{ type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id },
{ type: "synthetic", text: "parent note" },
])
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
@ -273,14 +274,14 @@ describe("SessionV2.create", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID,
finish: "stop",
cost: 0.75,
cost: Money.USD.make(0.75),
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
@ -533,7 +534,7 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
@ -553,8 +554,8 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" })
expect(shell?.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined()
}),
),
@ -565,7 +566,7 @@ describe("SessionV2.create", () => {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: "plan" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
@ -580,7 +581,7 @@ describe("SessionV2.create", () => {
const missing = SessionV2.ID.make("ses_missing_agent_switch")
expect(
yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe(
yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),

View file

@ -303,7 +303,6 @@ describe("SessionInstructions", () => {
const synthetic = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_test"),
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
description: "Loaded /repo/sub/AGENTS.md",
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
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"
@ -87,11 +88,11 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") })
// Not in the durable manifest, so reads must skip it without failing.
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") })
yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))

View file

@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
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"
@ -15,9 +16,11 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
@ -35,7 +38,8 @@ const sessionID = SessionV2.ID.make("ses_projector_test")
const created = DateTime.makeUnsafe(0)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const build = AgentV2.defaultID
const assistantRow = (
id: SessionMessage.ID,
@ -48,12 +52,108 @@ const assistantRow = (
type,
...data
} = encodeMessage(
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
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 }
}
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const events = yield* EventV2.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID })
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "auto",
error: { type: "compaction.failed", message: "Auto compaction failed" },
})
expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID })
}),
)
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
diff: "legacy patch",
files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }],
})
yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`)
const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()
if (!stored) return yield* Effect.die("Session row missing")
const storedRevert = fromRow(stored).revert
expect(String(storedRevert?.messageID)).toBe("msg_boundary")
expect(String(storedRevert?.snapshot)).toBe("tree")
expect(storedRevert?.files).toEqual([
{ file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
])
}),
)
it.effect("folds live compaction deltas into running memory state", () =>
Effect.gen(function* () {
const state = {
messages: [
SessionMessage.CompactionRunning.make({
id: SessionMessage.ID.make("msg_compaction"),
type: "compaction",
status: "running",
reason: "manual",
summary: "partial ",
recent: "recent",
time: { created },
}),
],
}
yield* SessionMessageUpdater.update(
SessionMessageUpdater.memory(state),
SessionEvent.Compaction.Delta.make({
id: EventV2.ID.make("evt_delta"),
type: "session.compaction.delta",
created,
data: { sessionID, text: "summary" },
}),
)
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
}),
)
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@ -89,7 +189,7 @@ describe("SessionProjector", () => {
1,
{ created },
{
cost: 0.5,
cost: Money.USD.make(0.5),
tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
},
),
@ -98,7 +198,7 @@ describe("SessionProjector", () => {
2,
{ created },
{
cost: 0.75,
cost: Money.USD.make(0.75),
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
},
),
@ -111,7 +211,7 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
})
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
messageID: boundary,
@ -132,7 +232,7 @@ describe("SessionProjector", () => {
(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,
cost: Money.USD.make(1.25),
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
@ -285,7 +385,7 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "build",
agent: build,
})
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
@ -327,6 +427,7 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "recent context",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
@ -336,18 +437,18 @@ describe("SessionProjector", () => {
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
.where(sql`${EventTable.type} like 'session.compaction.delta.%'`)
.all()
.pipe(Effect.orDie),
).toEqual([])
).toHaveLength(0)
expect(
yield* db
.select({ id: SessionMessageTable.id })
.select({ data: SessionMessageTable.data })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.type, "compaction"))
.all()
.pipe(Effect.orDie),
).toEqual([])
).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }])
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
@ -363,7 +464,7 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages.map((message) => message.type)).toEqual([
@ -379,7 +480,9 @@ describe("SessionProjector", () => {
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
shell: { command: "pwd", status: "exited", exit: 0 },
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
@ -419,11 +522,7 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const {
id: _,
type,
...data
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
yield* db
.insert(SessionMessageTable)
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
@ -433,7 +532,7 @@ describe("SessionProjector", () => {
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
agent: "build",
agent: build,
model,
})
.pipe(Effect.exit)
@ -450,7 +549,7 @@ describe("SessionProjector", () => {
const stale = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created },
@ -458,7 +557,7 @@ describe("SessionProjector", () => {
const completed = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
@ -493,7 +592,7 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
@ -503,7 +602,7 @@ describe("SessionProjector", () => {
})
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type })
const firstRow = yield* db
.select()
.from(SessionMessageTable)
@ -515,7 +614,7 @@ describe("SessionProjector", () => {
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
@ -574,7 +673,7 @@ describe("SessionProjector", () => {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
@ -586,13 +685,13 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages[0]).not.toHaveProperty("time.completed")
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
})
@ -608,7 +707,7 @@ describe("SessionProjector", () => {
})
expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({
sessionID,
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
}),
@ -661,13 +760,13 @@ describe("SessionProjector", () => {
.all()
.pipe(Effect.orDie)
const messages = rows.map((row) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages).toEqual([
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
@ -675,7 +774,7 @@ describe("SessionProjector", () => {
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
agent: build,
model,
content: [],
time: { created },

View file

@ -6,6 +6,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AgentV2 } from "@opencode-ai/core/agent"
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"
@ -105,7 +106,7 @@ const eventCount = (type: string) =>
),
)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const assistantRow = (id: SessionMessage.ID, seq: number) => {
const {
id: _,
@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
SessionMessage.Assistant.make({
id,
type: "assistant",
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [],
time: { created: DateTime.makeUnsafe(0) },
@ -677,7 +678,6 @@ describe("SessionV2.prompt", () => {
...data
} = encodeMessage({
id: messageID,
sessionID,
type: "synthetic",
text: "Existing history",
time: { created: DateTime.makeUnsafe(0) },

View file

@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
const build = AgentV2.defaultID
describe("toLLMMessages", () => {
test("omits empty assistant turns", () => {
@ -19,7 +20,7 @@ describe("toLLMMessages", () => {
SessionMessage.Assistant.make({
id: id(value),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content,
time: { created, completed: created },
@ -56,7 +57,7 @@ describe("toLLMMessages", () => {
SessionMessage.AgentSelected.make({
id: id("agent"),
type: "agent-switched",
agent: "build",
agent: build,
time: { created },
}),
SessionMessage.ModelSelected.make({
@ -82,24 +83,16 @@ describe("toLLMMessages", () => {
SessionMessage.Synthetic.make({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
shell: Shell.Info.make({
id: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_test.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 0 },
}),
shellID: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
exit: 0,
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
}),
@ -282,7 +275,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
@ -295,7 +288,7 @@ Recent work
type: "tool",
id: "pending",
name: "read",
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }),
time: { created },
}),
SessionMessage.AssistantTool.make({
@ -437,7 +430,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -467,7 +460,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-failed"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -536,7 +529,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
@ -631,7 +624,7 @@ Recent work
SessionMessage.Assistant.make({
id: id("assistant-alias"),
type: "assistant",
agent: "build",
agent: build,
model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
@ -131,7 +132,7 @@ describe("SessionRunnerModel", () => {
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -170,7 +171,7 @@ describe("SessionRunnerModel", () => {
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -200,7 +201,7 @@ describe("SessionRunnerModel", () => {
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
@ -236,7 +237,7 @@ describe("SessionRunnerModel", () => {
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },

View file

@ -1,7 +1,9 @@
import { expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLMEvent } from "@opencode-ai/llm"
import { Money } from "@opencode-ai/schema/money"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
@ -40,7 +42,7 @@ const capture = () => {
published,
publisher: createLLMEventPublisher(events, {
sessionID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
@ -193,7 +195,7 @@ test("content-filter finish retains failure evidence until step closeout", async
if (!settlement) throw new Error("Expected content-filter settlement")
await Effect.runPromise(
publisher.publishStepFailure({
cost: 1.25,
cost: Money.USD.make(1.25),
tokens: settlement.tokens,
}),
)

View file

@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
@ -135,8 +136,23 @@ 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: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
{
tier: { type: "context", size: 100 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(4),
cache: {
read: Money.USDPerMillionTokens.make(0.2),
write: Money.USDPerMillionTokens.make(0.6),
},
},
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
@ -146,10 +162,20 @@ test("calculates step cost using the matching context tier", () => {
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 } }],
[
{
tier: { type: "context", size: 100 },
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(4),
cache: {
read: Money.USDPerMillionTokens.make(0.2),
write: Money.USDPerMillionTokens.make(0.6),
},
},
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } },
),
).toBe(0)
).toBe(Money.USD.zero)
})
const authorizations: Tool.Context[] = []
@ -485,7 +511,7 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess
const hostedCall = (id: string, query: string) =>
LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true })
const requireAssistant = (messages: readonly SessionMessage.Message[]) => {
const requireAssistant = (messages: readonly SessionMessage.Info[]) => {
const assistant = messages.find((message) => message.type === "assistant")
if (!assistant) throw new Error("Assistant message missing")
return assistant
@ -581,7 +607,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
LLMEvent.toolInputStart({ id, name: "echo" }),
...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
]
const expectedContent = { type: "tool", id, state: { status: "pending", input: text } }
const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } }
return {
delta: SessionEvent.Tool.Input.Delta,
partialEvents,
@ -1056,7 +1082,7 @@ describe("SessionRunnerLLM", () => {
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
})
yield* admit(session, "Second")
yield* session.resume(sessionID)
@ -1082,7 +1108,7 @@ describe("SessionRunnerLLM", () => {
return events
.publish(SessionEvent.AgentSelected, {
sessionID,
agent: "reviewer",
agent: AgentV2.ID.make("reviewer"),
})
.pipe(Effect.asVoid)
})
@ -1265,6 +1291,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
@ -1308,10 +1335,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
yield* admit(session, "Steer after compaction")
yield* session.prompt({
@ -1370,6 +1394,57 @@ describe("SessionRunnerLLM", () => {
type: "compaction",
status: "failed",
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
it.effect("settles an admitted manual compaction that cannot start", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
error: { message: "Compaction could not start" },
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed")
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
})
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
}),
)
@ -1511,9 +1586,10 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
expect(context.some((message) => message.type === "compaction")).toBe(false)
expect(context.slice(-2)).toMatchObject([
expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }))
expect(context.slice(-3)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "compaction", status: "failed", reason: "auto" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
])
}),
@ -1540,7 +1616,9 @@ describe("SessionRunnerLLM", () => {
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
streamGate = undefined
expect(requests).toHaveLength(2)
expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false)
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }),
)
}),
)
@ -1557,6 +1635,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
@ -2299,7 +2378,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
@ -2356,7 +2435,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
@ -2407,7 +2486,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {

View file

@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, {
list: () =>
Effect.succeed([
SkillV2.Info.make({
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
@ -60,10 +61,10 @@ describe("SessionV2.skill", () => {
const session = yield* sessions.create({ location })
const id = SessionMessage.ID.make("msg_caller_skill")
yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false })
yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }),
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
)
}),
)

View file

@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
@ -51,7 +52,7 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: "build",
agent: AgentV2.ID.make("build"),
model,
})
const readAssistant = Effect.gen(function* () {

View file

@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => {
const schemas = [
[AgentV2.ID, Agent.ID],
[AgentV2.Name, Agent.Name],
[AgentV2.Color, Agent.Color],
[AgentV2.Info, Agent.Info],
[coreCommand.Info, Command.Info],
@ -145,7 +146,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],
[coreSessionMessage.Shell, SessionMessage.Shell],
[coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending],
[coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming],
[coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning],
[coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted],
[coreSessionMessage.ToolStateError, SessionMessage.ToolStateError],
@ -156,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.AssistantContent, SessionMessage.AssistantContent],
[coreSessionMessage.Assistant, SessionMessage.Assistant],
[coreSessionMessage.Compaction, SessionMessage.Compaction],
[coreSessionMessage.Message, SessionMessage.Message],
[coreSessionMessage.Info, SessionMessage.Info],
[coreSessionTodo.Info, SessionTodo.Info],
[coreSessionTodo.Event, SessionTodo.Event],
[coreSkill.DirectorySource, Skill.DirectorySource],

View file

@ -88,13 +88,15 @@ describe("SkillV2", () => {
])
expect(yield* skill.list()).toEqual([
SkillV2.Info.make({
name: "foo",
id: SkillV2.ID.make("foo"),
name: SkillV2.Name.make("foo"),
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
name: "review",
id: SkillV2.ID.make("review"),
name: SkillV2.Name.make("review"),
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
@ -129,8 +131,8 @@ describe("SkillV2", () => {
const skill = yield* SkillV2.Service
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")])
expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")])
expect(pulls).toBe(1)
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
}),
@ -165,7 +167,8 @@ metadata:
expect(yield* skill.list()).toEqual([
{
name: "manual",
id: SkillV2.ID.make("manual"),
name: SkillV2.Name.make("manual"),
description: "Manual only",
slash: true,
autoinvoke: false,

View file

@ -11,24 +11,28 @@ import { it } from "../lib/effect"
const build = AgentV2.ID.make("build")
const effect = SkillV2.Info.make({
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Build applications with Effect",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Effect guidance",
})
const hidden = SkillV2.Info.make({
name: "hidden",
id: SkillV2.ID.make("hidden"),
name: SkillV2.Name.make("Hidden"),
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
content: "Undescribed guidance",
})
const denied = SkillV2.Info.make({
name: "denied",
id: SkillV2.ID.make("denied"),
name: SkillV2.Name.make("Denied"),
description: "Must not be advertised",
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
content: "Denied guidance",
})
const manual = SkillV2.Info.make({
name: "manual",
id: SkillV2.ID.make("manual"),
name: SkillV2.Name.make("Manual"),
description: "Load only when explicitly selected",
autoinvoke: false,
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
@ -59,7 +63,8 @@ describe("SkillGuidance", () => {
"Use the skill tool to load a skill when a task matches its description.",
"<available_skills>",
" <skill>",
" <name>effect</name>",
" <id>effect</id>",
" <name>Effect</name>",
" <description>Build applications with Effect</description>",
" </skill>",
"</available_skills>",
@ -74,7 +79,7 @@ describe("SkillGuidance", () => {
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
).toMatchObject({
_tag: "Updated",
text: "The following skills are no longer available and must not be used: effect.",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
}).pipe(Effect.provide(layer(() => skills)))
})
@ -82,7 +87,8 @@ describe("SkillGuidance", () => {
it.effect("announces added and removed skills as deltas without restating the list", () => {
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
const debugging = SkillV2.Info.make({
name: "debugging",
id: SkillV2.ID.make("debugging"),
name: SkillV2.Name.make("Debugging"),
description: "Diagnose hard bugs",
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
content: "Debugging guidance",
@ -103,7 +109,8 @@ describe("SkillGuidance", () => {
text: [
"New skills are available in addition to those previously listed:",
" <skill>",
" <name>debugging</name>",
" <id>debugging</id>",
" <name>Debugging</name>",
" <description>Diagnose hard bugs</description>",
" </skill>",
].join("\n"),
@ -117,7 +124,7 @@ describe("SkillGuidance", () => {
)
expect(removed).toMatchObject({
_tag: "Updated",
text: "The following skills are no longer available and must not be used: effect.",
text: "The following skill IDs are no longer available and must not be used: effect.",
})
}).pipe(Effect.provide(layer(() => skills)))
})
@ -192,7 +199,7 @@ describe("SkillGuidance", () => {
const guidance = yield* SkillGuidance.Service
expect(
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
).toContain("<name>effect</name>")
).toContain("<name>Effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})

View file

@ -56,7 +56,7 @@ describe("Snapshot", () => {
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* snapshot.preview({ files: plan, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
yield* snapshot.restore({ files: plan })
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")

View file

@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -104,7 +105,7 @@ const executionNode = makeGlobalNode({
sessionID: id,
assistantMessageID,
finish: "stop",
cost: 0,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
@ -442,10 +443,7 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }),
)
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })

View file

@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
describe("SkillTool", () => {
it.live("lists available skills, authorizes the selected name, and loads model-facing content", () =>
it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@ -42,7 +42,8 @@ describe("SkillTool", () => {
)
const info: SkillV2.Info = {
name: "effect",
id: SkillV2.ID.make("effect"),
name: SkillV2.Name.make("Effect"),
description: "Use Effect",
location: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
@ -102,7 +103,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
type: "text",
@ -113,11 +114,11 @@ describe("SkillTool", () => {
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "effect" } },
output: { structured: { name: "Effect" } },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
@ -127,7 +128,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true
@ -135,12 +136,13 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
deny = false
const flat = SkillV2.Info.make({
name: "public",
id: SkillV2.ID.make("public"),
name: SkillV2.Name.make("Public"),
description: "Public guidance",
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
content: "Public",
@ -156,7 +158,7 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
}).pipe(Effect.provide(skillToolLayer))

View file

@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -70,7 +71,7 @@ const executionNode = makeGlobalNode({
sessionID,
assistantMessageID,
finish: "stop",
cost: 0,
cost: Money.USD.zero,
tokens,
})
})