refactor(schema): apply session review decisions (#35793)
This commit is contained in:
parent
d27746e5b3
commit
ed6ad272ec
142 changed files with 4239 additions and 3174 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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"), () => {})
|
||||
|
|
|
|||
|
|
@ -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", () =>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"] } },
|
||||
|
|
|
|||
|
|
@ -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 })))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue