chore(observability): merge v2

This commit is contained in:
starptech 2026-07-09 06:36:36 +02:00
commit f4e661f874
276 changed files with 17001 additions and 3755 deletions

View file

@ -8,13 +8,15 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
@ -22,6 +24,33 @@ const defaultPermissions = [
] satisfies PermissionV2.Ruleset
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
"allow",
)
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
expect(PermissionV2.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
}),
)
it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
"deny",
)
}),
)
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
@ -280,3 +309,51 @@ Use native v2 fields.`,
),
)
})
function loadHomePermissions(home: string) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
external_directory: {
"~/p/**": "allow",
"/some/~/path": "deny",
"$HOMELESS/**": "deny",
},
bash: {
"$HOME/private/**": "deny",
},
},
agent: {
build: {
permission: {
external_directory: {
"$HOME/cache/**": "deny",
},
},
},
},
}),
),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
)
const agent = yield* agents.get(build)
if (!agent) throw new Error("expected configured build agent")
return agent.permissions
})
}

View file

@ -44,7 +44,7 @@ function testLayer(
)
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
}
@ -112,6 +112,7 @@ describe("Config", () => {
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Config.Document({ type: "document", info: new Config.Info({}) }),
new Config.Document({
type: "document",
@ -848,11 +849,19 @@ describe("Config", () => {
const root = path.join(tmp.path, "repo")
const parent = path.join(root, "packages")
const directory = path.join(parent, "app")
const globalAgents = path.join(global, "home", ".agents")
const globalClaude = path.join(global, "home", ".claude")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(globalAgents, { recursive: true })
await fs.mkdir(globalClaude, { recursive: true })
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(path.join(root, ".agents"), { recursive: true })
await fs.mkdir(path.join(root, ".claude"), { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await Promise.all([
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
@ -878,6 +887,16 @@ describe("Config", () => {
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")),
])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
])
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"root",
@ -887,6 +906,12 @@ describe("Config", () => {
"directory-dot",
])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
"global",
AbsolutePath.make(global),
"root",

View file

@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({
type: "document",
@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => {
)
expect(sources).toEqual([
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),

View file

@ -18,6 +18,7 @@ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/
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 genericSessionInputMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
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"
@ -369,7 +370,7 @@ describe("DatabaseMigration", () => {
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_compaction_idx" },
{ name: "session_input_session_pending_type_delivery_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" },
@ -607,7 +608,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
@ -701,6 +702,56 @@ describe("DatabaseMigration", () => {
)
})
test("migrates prompt inbox rows and lifecycle events to generic user input", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
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`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', 'prompt', '{"text":"hello"}', 'queue', 4, NULL, 1)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('empty', 'session', 'prompt', NULL, 'steer', 6, NULL, 2)`,
)
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`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('admitted', 'session', 4, 1, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"input","prompt":{"text":"hello"},"delivery":"queue"}')`,
)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('promoted', 'session', 5, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"input"}')`,
)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-admitted', 'session', 6, 2, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"empty","prompt":null,"delivery":"steer"}')`,
)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
)
yield* DatabaseMigration.applyOnly(db, [genericSessionInputMigration])
expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
{ id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
])
expect(yield* db.all(sql`SELECT type, data FROM event ORDER BY seq`)).toEqual([
{
type: "session.input.admitted.1",
data: '{"sessionID":"session","inputID":"input","input":{"type":"user","data":{"text":"hello"},"delivery":"queue"}}',
},
{
type: "session.input.promoted.1",
data: '{"sessionID":"session","inputID":"input"}',
},
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {

View file

@ -185,35 +185,33 @@ describeWatcher("LocationWatcher", () => {
),
)
it.live("watches non-git roots", () =>
it.live("skips non-git roots", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(directory, "plain.txt")
yield* ready(directory)
expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
file,
event: "add",
})
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
}),
),
)
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp((directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
{ git: true },
),
)
@ -225,7 +223,10 @@ describeWatcher("LocationWatcher", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
yield* ready(tmp.path).pipe(
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
Effect.scoped,
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events),

View file

@ -14,6 +14,7 @@ const formID = Form.ID.create("frm_test")
const input = {
id: formID,
sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form",
mode: "form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
@ -45,10 +46,12 @@ describe("Form", () => {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "MCP input",
mode: "form",
fields: [{ key: "name", type: "string", required: true }],
})
expect(created.sessionID).toBe("global")
expect(created.title).toBe("MCP input")
const owned = yield* service.list({ sessionID: "global" })
expect(owned.map((form) => form.id)).toEqual([created.id])
@ -64,6 +67,7 @@ describe("Form", () => {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "Conditional form",
mode: "form",
fields: [
{ key: "confirm", type: "boolean", required: true },
@ -96,6 +100,7 @@ describe("Form", () => {
]
const created = yield* service.create({
sessionID: "global",
title: "Multiselect form",
mode: "form",
fields: [
{ key: "langs", type: "multiselect", options },
@ -118,6 +123,7 @@ describe("Form", () => {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "Dependent form",
mode: "form",
fields: [
{ key: "a", type: "boolean" },
@ -160,6 +166,7 @@ describe("Form", () => {
]
const created = yield* service.create({
sessionID: "global",
title: "Selection form",
mode: "form",
fields: [
{ key: "langs", type: "multiselect", options },
@ -191,6 +198,7 @@ describe("Form", () => {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "Cascading form",
mode: "form",
fields: [
{ key: "a", type: "boolean" },
@ -213,7 +221,7 @@ describe("Form", () => {
Effect.gen(function* () {
const service = yield* Form.Service
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
service.create({ sessionID: "global", mode: "form", fields }).pipe(Effect.flip)
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
expect(
yield* flipCreate([

View file

@ -1,14 +1,33 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
const failingCredentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
create: () => Effect.die(new Error("credential persistence failed")),
update: () => Effect.void,
remove: () => Effect.void,
}),
),
deps: [],
})
const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
)
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
@ -254,6 +273,47 @@ describe("Integration", () => {
}),
)
failingIt.effect("fails the attempt when credential persistence fails", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
}),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const exit = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "failed",
message: "credential persistence failed",
time: attempt.time,
})
}),
)
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service

View file

@ -92,6 +92,73 @@ describe("OpencodePlugin", () => {
}),
)
it.live("uses a canonical custom server throughout device authorization", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
requests.push(`${request.method} ${url.pathname}`)
if (url.pathname.endsWith("/auth/device/code")) {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
expires_in: 60,
interval: 0,
})
}
if (url.pathname.endsWith("/auth/device/token")) {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
}
if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }])
return new Response("Not found", { status: 404 })
},
})
return { requests, server }
}),
({ requests, server }) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
})
expect(attempt.url).toBe(`${server.url.origin}/verify`)
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
expect(requests).toContain("POST /console/auth/device/code")
expect(requests).toContain("POST /console/auth/device/token")
expect(requests).toContain("GET /console/api/user")
expect(requests).toContain("GET /console/api/orgs")
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
metadata: { server: `${server.url.origin}/console` },
})
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).connection
.oauth({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: { server: "ftp://console.example.com" },
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Integration.AuthorizationError)
expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)")
}),
)
it.live("loads providers and models from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {

View file

@ -17,7 +17,6 @@ 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"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
@ -84,14 +83,16 @@ describe("SessionV2.compact", () => {
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
const prompt = Prompt.make({ text: "Please compact this session history." })
yield* events.publish(SessionEvent.PromptAdmitted, {
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID: created.id,
inputID: messageID,
prompt,
delivery: "steer",
input: {
type: "user",
data: { text: "Please compact this session history." },
delivery: "steer",
},
})
yield* events.publish(SessionEvent.PromptPromoted, {
yield* events.publish(SessionEvent.InputPromoted, {
sessionID: created.id,
inputID: messageID,
})

View file

@ -17,7 +17,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -193,14 +192,12 @@ describe("SessionV2.create", () => {
const parent = yield* session.create({ location, title: "Parent" })
const admitted = yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "First" }),
text: "First",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
text: "parent note",
})
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionInput.promoteSteers(db, events, parent.id)
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
@ -222,19 +219,25 @@ describe("SessionV2.create", () => {
})
expect(yield* SessionInput.find(db, forkContext[0].id)).toMatchObject({
sessionID: forked.id,
prompt: { text: "First" },
type: "user",
data: { text: "First" },
promotedSeq: 2,
})
expect(yield* SessionInput.find(db, forkContext[1].id)).toMatchObject({
sessionID: forked.id,
type: "synthetic",
data: { text: "parent note" },
})
yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "Parent changed" }),
text: "Parent changed",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* session.prompt({
sessionID: forked.id,
prompt: PromptInput.Prompt.make({ text: "Child continues" }),
text: "Child continues",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, forked.id)
@ -246,7 +249,7 @@ describe("SessionV2.create", () => {
Array.from(yield* Stream.runCollect(logEvents(session, forked.id))).map(
(event): number | undefined => event.durable?.seq,
),
).toEqual([0, 4, 5])
).toEqual([0, 5, 6])
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
}),
)
@ -259,13 +262,13 @@ describe("SessionV2.create", () => {
const parent = yield* session.create({ location })
const first = yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "First" }),
text: "First",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
const second = yield* session.prompt({
sessionID: parent.id,
prompt: PromptInput.Prompt.make({ text: "Second" }),
text: "Second",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id)
@ -410,7 +413,7 @@ describe("SessionV2.create", () => {
const created = yield* session.create({ location })
yield* session.prompt({
sessionID: created.id,
prompt: PromptInput.Prompt.make({ text: "Hello" }),
text: "Hello",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, created.id)
@ -418,8 +421,12 @@ describe("SessionV2.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.prompt.promoted" },
{
durable: { seq: 1 },
type: "session.input.admitted",
data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } },
},
{ durable: { seq: 2 }, type: "session.input.promoted" },
])
}),
)
@ -432,7 +439,7 @@ describe("SessionV2.create", () => {
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
const admitted = yield* session.prompt({
sessionID: created.id,
prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }),
text: "Replay lifecycle",
resume: false,
})
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
@ -476,7 +483,8 @@ describe("SessionV2.create", () => {
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
prompt: { text: "Replay lifecycle" },
type: "user",
data: { text: "Replay lifecycle" },
delivery: "steer",
admittedSeq: 1,
})
@ -486,7 +494,8 @@ describe("SessionV2.create", () => {
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
prompt: { text: "Replay lifecycle" },
type: "user",
data: { text: "Replay lifecycle" },
delivery: "steer",
admittedSeq: 1,
promotedSeq: 2,
@ -504,8 +513,8 @@ describe("SessionV2.create", () => {
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)],
[1, EventV2.versionedType(SessionEvent.InputAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.InputPromoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),

View file

@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
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"
@ -266,28 +265,26 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.PromptAdmitted, {
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
input: { type: "user", data: { text: "first" }, delivery: "steer" },
})
yield* events.publish(
SessionEvent.PromptPromoted,
SessionEvent.InputPromoted,
{
sessionID,
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: EventV2.ID.make("evt_z") },
)
yield* events.publish(SessionEvent.PromptAdmitted, {
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
input: { type: "user", data: { text: "second" }, delivery: "steer" },
})
yield* events.publish(
SessionEvent.PromptPromoted,
SessionEvent.InputPromoted,
{
sessionID,
inputID: SessionMessage.ID.make("msg_second"),
@ -344,12 +341,11 @@ describe("SessionProjector", () => {
const admitted = yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* events.publish(SessionEvent.PromptPromoted, {
const event = yield* events.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: id,
})

View file

@ -18,7 +18,6 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -174,16 +173,17 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
resume: false,
})
expect(message.prompt.text).toBe("Fix the failing tests")
expect(message.data.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
prompt: { text: "Fix the failing tests" },
type: "user",
data: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
@ -198,7 +198,7 @@ describe("SessionV2.prompt", () => {
const boundary = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "boundary" }),
text: "boundary",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, sessionID)
@ -210,7 +210,7 @@ describe("SessionV2.prompt", () => {
})
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false })
yield* session.prompt({ sessionID, text: "after revert", resume: false })
expect((yield* session.get(sessionID)).revert).toBeUndefined()
expect(
@ -222,6 +222,35 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("holds synthetic input behind a staged revert and discards it when committed", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const boundary = yield* session.prompt({
sessionID,
text: "boundary",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, sessionID)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
})
wakeCalls.length = 0
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
expect(wakeCalls).toEqual([])
expect(yield* SessionInput.find(db, completion.id)).toMatchObject({ type: "synthetic" })
yield* session.revert.commit(sessionID)
expect(yield* SessionInput.find(db, completion.id)).toBeUndefined()
}),
)
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
@ -231,14 +260,12 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: {
text: "Inspect this image",
files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
},
text: "Inspect this image",
files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
resume: false,
})
expect(message.prompt.files).toEqual([
expect(message.data.files).toEqual([
{
data: uri.slice(uri.indexOf(",") + 1),
mime: "image/png",
@ -248,8 +275,8 @@ describe("SessionV2.prompt", () => {
},
])
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("prompt")
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
expect(stored?.type).toBe("user")
if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files)
}),
)
@ -265,21 +292,19 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: {
text: "Inspect this",
files: [{ uri: sourceUri.href, name: "main.ts" }],
},
text: "Inspect this",
files: [{ uri: sourceUri.href, name: "main.ts" }],
resume: false,
})
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(
Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
.toString("utf8")
.replace(/\r$/, ""),
).toBe('import { describe, expect } from "bun:test"')
@ -294,17 +319,18 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
text: "Inspect this",
files: [{ uri, name: "source" }],
resume: false,
})
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
@ -327,11 +353,12 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] },
text: "Inspect this image",
files: [{ uri: pathToFileURL(source).href }],
resume: false,
})
expect(message.prompt.files).toEqual([
expect(message.data.files).toEqual([
{
data: bytes.toString("base64"),
mime: "image/png",
@ -340,7 +367,7 @@ describe("SessionV2.prompt", () => {
},
])
const stored = yield* admitted(message.id)
expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files)
expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files)
}),
)
@ -352,11 +379,12 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] },
text: "Inspect this",
files: [{ uri, name: "main.ts" }],
resume: false,
})
expect(message.prompt.files).toEqual([
expect(message.data.files).toEqual([
{
data: Buffer.from("export const value = 1\n").toString("base64"),
mime: "text/plain",
@ -376,7 +404,8 @@ describe("SessionV2.prompt", () => {
const error = yield* session
.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] },
text: "Inspect this",
files: [{ uri, name: "image.png" }],
resume: false,
})
.pipe(Effect.flip)
@ -402,22 +431,22 @@ describe("SessionV2.prompt", () => {
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.prompt.admitted"],
[1, "session.prompt.admitted"],
[2, "session.prompt.promoted"],
[3, "session.prompt.promoted"],
[0, "session.input.admitted"],
[1, "session.input.admitted"],
[2, "session.input.promoted"],
[3, "session.input.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "session.prompt.admitted"]])
).toEqual([[1, "session.input.admitted"]])
}),
)
@ -427,7 +456,7 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
resume: false,
})
@ -446,7 +475,7 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false }
const input = { sessionID, text: "Fix the failing tests", resume: false }
const first = yield* session.prompt(input)
const second = yield* session.prompt(input)
@ -464,7 +493,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
resume: false,
}
@ -484,7 +513,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }),
text: "Recover committed prompt",
resume: false,
}
const first = yield* session.prompt(input)
@ -505,13 +534,13 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
sessionID,
id: messageID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
})
const failure = yield* session
.prompt({
sessionID,
id: messageID,
prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }),
text: "Delete the failing tests",
resume: false,
})
.pipe(Effect.flip)
@ -530,14 +559,14 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
resume: false,
})
const failure = yield* session
.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
delivery: "queue",
resume: false,
})
@ -554,7 +583,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }),
text: "Fix the failing tests",
resume: false,
}
@ -563,7 +592,7 @@ describe("SessionV2.prompt", () => {
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@ -576,7 +605,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
text: "Promote once",
resume: false,
})
@ -585,7 +614,7 @@ describe("SessionV2.prompt", () => {
{ concurrency: "unbounded" },
)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@ -603,9 +632,11 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Replay pending" }),
text: "Replay pending",
resume: false,
})
const syntheticID = SessionMessage.ID.create()
yield* session.synthetic({ id: syntheticID, sessionID, text: "Replay synthetic", resume: false })
const recorded = yield* db
.select()
.from(EventTable)
@ -631,7 +662,16 @@ describe("SessionV2.prompt", () => {
})),
)
expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } })
expect(yield* admitted(messageID)).toMatchObject({
id: messageID,
type: "user",
data: { text: "Replay pending" },
})
expect(yield* admitted(syntheticID)).toMatchObject({
id: syntheticID,
type: "synthetic",
data: { text: "Replay synthetic" },
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(wakeCalls).toEqual([])
}),
@ -656,11 +696,9 @@ describe("SessionV2.prompt", () => {
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" })
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false })
const failure = yield* session
.prompt({ id: messageID, sessionID: other, prompt, resume: false })
.prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false })
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
@ -692,7 +730,7 @@ describe("SessionV2.prompt", () => {
.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
text: "Conflicting prompt",
resume: false,
})
.pipe(Effect.flip)
@ -709,7 +747,7 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) })
yield* session.prompt({ sessionID, text: "Run by default" })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
@ -725,7 +763,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Run explicitly" }),
text: "Run explicitly",
resume: true,
})
@ -741,10 +779,150 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false })
yield* session.prompt({ sessionID, text: "Do not run", resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
}),
)
it.effect("treats prompt metadata as durable retry identity", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = {
id: messageID,
sessionID,
text: "Deploy",
metadata: { source: "api" },
resume: false,
}
const first = yield* session.prompt(input)
const retried = yield* session.prompt(input)
const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
expect(retried).toEqual(first)
expect(first.data.metadata).toEqual({ source: "api" })
expect(failure._tag).toBe("Session.PromptConflictError")
}),
)
it.effect("durably admits synthetic input before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const input = yield* session.synthetic({
id: messageID,
sessionID,
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
resume: false,
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(input.id)).toMatchObject({
type: "synthetic",
sessionID,
delivery: "steer",
data: {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
})
yield* SessionInput.promoteSteers(db, events, sessionID)
expect(yield* session.messages({ sessionID })).toMatchObject([
{
id: messageID,
type: "synthetic",
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
])
}),
)
it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const database = yield* Database.Service
const input = { id: messageID, sessionID, text: "Completed", resume: false }
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionInput.promoteSteers(database.db, events, sessionID)
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
expect(entries[1]).toEqual(entries[0])
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", promotedSeq: expect.any(Number) })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
it.effect("keeps synthetic queue input pending until the queue boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const input = yield* session.synthetic({
sessionID,
text: "Queued completion",
delivery: "queue",
resume: false,
})
expect(input.delivery).toBe("queue")
expect(yield* SessionInput.promoteSteers(db, events, sessionID)).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
])
}),
)
it.effect("promotes prompt and synthetic steers in admission order", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({
sessionID,
text: "First prompt",
resume: false,
})
yield* session.synthetic({ sessionID, text: "Background completion", resume: false })
yield* session.prompt({
sessionID,
text: "Second prompt",
resume: false,
})
yield* SessionInput.promoteSteers(db, events, sessionID)
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
message.type === "user" || message.type === "synthetic" ? message.text : message.type,
),
).toEqual(["First prompt", "Background completion", "Second prompt"])
}),
)
})

View file

@ -24,7 +24,6 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
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"
@ -205,7 +204,7 @@ describe("SessionRunnerLLM recorded", () => {
const session = yield* SessionV2.Service
const prompt = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }),
text: "Say hello in one short sentence.",
resume: false,
})
@ -236,8 +235,8 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.prompt.admitted.1",
"session.prompt.promoted.1",
"session.input.admitted.1",
"session.input.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",

View file

@ -65,7 +65,6 @@ import { Snapshot } from "@opencode-ai/core/snapshot"
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"
@ -109,6 +108,7 @@ const requests: LLMRequest[] = []
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
let streamGate: Deferred.Deferred<void> | undefined
let streamStarted: Deferred.Deferred<void> | undefined
let streamFailure: LLMError | undefined
@ -123,6 +123,7 @@ const client = Layer.succeed(
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
if (responseStreams) return responseStreams.shift() ?? Stream.empty
if (responseStream) {
const stream = responseStream
responseStream = undefined
@ -444,8 +445,7 @@ const it = testEffect(
)
const sessionID = SessionV2.ID.make("ses_runner_test")
const otherSessionID = SessionV2.ID.make("ses_runner_other")
const admit = (session: SessionV2.Interface, text: string) =>
session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false })
const admit = (session: SessionV2.Interface, text: string) => session.prompt({ sessionID, text, resume: false })
const insertSession = (id: SessionV2.ID) =>
Effect.gen(function* () {
@ -482,6 +482,7 @@ const setup = Effect.gen(function* () {
responses = undefined
streamFailure = undefined
responseStream = undefined
responseStreams = undefined
streamGate = undefined
streamStarted = undefined
toolExecutionGate = undefined
@ -781,7 +782,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use echo" }), resume: false })
yield* session.prompt({ sessionID, text: "Use echo", resume: false })
const usage = new Usage({
inputTokens: 8,
outputTokens: 3,
@ -856,7 +857,7 @@ describe("SessionRunnerLLM", () => {
})
yield* session.prompt({
sessionID: child.id,
prompt: PromptInput.Prompt.make({ text: "Explore" }),
text: "Explore",
resume: false,
})
@ -898,7 +899,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Use echo twice" }),
text: "Use echo twice",
resume: false,
})
responses = [
@ -933,7 +934,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail tool" }), resume: false })
yield* session.prompt({ sessionID, text: "Fail tool", resume: false })
responses = [
[
LLMEvent.stepStart({ index: 0 }),
@ -1010,7 +1011,7 @@ describe("SessionRunnerLLM", () => {
const message = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Run automatically" }),
text: "Run automatically",
})
yield* session.wait(sessionID)
@ -1021,6 +1022,34 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs a follow-up when synthetic input arrives during an active continuation", () =>
Effect.gen(function* () {
const session = yield* setup
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
responseStreams = [
Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })),
Stream.unwrap(
Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseSecond)),
Effect.as(Stream.fromIterable(reply.stop())),
),
),
Stream.fromIterable(reply.text("Handled completion", "text-completion")),
]
yield* admit(session, "Start background work")
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(secondStarted)
yield* session.synthetic({ sessionID, text: "Background work completed" })
yield* Deferred.succeed(releaseSecond, undefined)
yield* Fiber.join(running)
expect(requests).toHaveLength(3)
expect(userTexts(requests[2]!)).toContain("Background work completed")
}),
)
it.effect("streams one request with registry definitions from chronological V2 user history", () =>
Effect.gen(function* () {
const session = yield* setup
@ -1049,7 +1078,7 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "First" }),
text: "First",
resume: false,
})
@ -1068,7 +1097,7 @@ describe("SessionRunnerLLM", () => {
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) })
yield* session.prompt({ id: messageID, sessionID, text: "First" })
yield* session.wait(sessionID)
expect(requests).toHaveLength(1)
@ -1335,7 +1364,7 @@ describe("SessionRunnerLLM", () => {
.run()
.pipe(Effect.orDie)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false })
yield* session.prompt({ sessionID, text: "Inspect files", resume: false })
requests.length = 0
response = []
@ -1356,7 +1385,7 @@ describe("SessionRunnerLLM", () => {
const release = yield* Deferred.make<void>()
pluginFlushHook = Deferred.await(release)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false })
yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false })
requests.length = 0
response = []
@ -1645,9 +1674,10 @@ describe("SessionRunnerLLM", () => {
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
yield* admit(session, "Steer after compaction")
yield* session.synthetic({ sessionID, text: "Completion after compaction", resume: false })
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }),
text: "Queue after compaction",
delivery: "queue",
resume: false,
})
@ -1659,6 +1689,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Steer after compaction")
expect(userTexts(requests[2])).toContain("Completion after compaction")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
@ -1691,7 +1722,7 @@ describe("SessionRunnerLLM", () => {
const compaction = yield* session.compact({ sessionID })
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Continue after failure" }),
text: "Continue after failure",
delivery: "queue",
resume: false,
})
@ -2447,7 +2478,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) })
yield* session.prompt({ sessionID, text: "Change direction" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@ -2480,7 +2511,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }),
text: "Wait until continuation ends",
delivery: "queue",
})
yield* Deferred.succeed(streamGate, undefined)
@ -2512,7 +2543,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }),
text: "Run after interrupt",
delivery: "queue",
})
yield* session.interrupt(sessionID)
@ -2546,7 +2577,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }),
text: "Steer after interrupt",
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
@ -2577,8 +2608,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@ -2602,7 +2633,7 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Start steering")
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Queue for later" }),
text: "Queue for later",
delivery: "queue",
resume: false,
})
@ -2629,16 +2660,17 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
streamGate = secondGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) })
yield* session.prompt({ sessionID, text: "Steer before next queued input" })
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }),
text: "Also steer before next queued input",
})
yield* session.synthetic({ sessionID, text: "Background completion before next queued input" })
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@ -2651,12 +2683,14 @@ describe("SessionRunnerLLM", () => {
"Queue first",
"Steer before next queued input",
"Also steer before next queued input",
"Background completion before next queued input",
])
expect(userTexts(requests[3]!)).toEqual([
"Start working",
"Queue first",
"Steer before next queued input",
"Also steer before next queued input",
"Background completion before next queued input",
"Queue second",
])
}),
@ -2673,8 +2707,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) })
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) })
yield* session.prompt({ sessionID, text: "First steer" })
yield* session.prompt({ sessionID, text: "Second steer" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@ -2700,7 +2734,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) })
yield* session.prompt({ sessionID, text: "Recover with this" })
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
@ -2859,7 +2893,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Wait in queue" }),
text: "Wait in queue",
delivery: "queue",
resume: false,
})
@ -2878,7 +2912,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* events.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* admit(session, "Recover promoted input")
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@ -2898,7 +2932,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const events = yield* EventV2.Service
yield* events.listen((event) =>
event.type === SessionEvent.PromptPromoted.type
event.type === SessionEvent.InputPromoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
@ -2918,7 +2952,7 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Run first")
yield* session.prompt({
sessionID: otherSessionID,
prompt: PromptInput.Prompt.make({ text: "Run second" }),
text: "Run second",
resume: false,
})
@ -2953,12 +2987,12 @@ describe("SessionRunnerLLM", () => {
yield* insertSession(otherLongSessionID)
yield* session.prompt({
sessionID: longSessionID,
prompt: PromptInput.Prompt.make({ text: "Run long session" }),
text: "Run long session",
resume: false,
})
yield* session.prompt({
sessionID: otherLongSessionID,
prompt: PromptInput.Prompt.make({ text: "Run other long session" }),
text: "Run other long session",
resume: false,
})
@ -3523,7 +3557,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) })
yield* session.prompt({ sessionID, text: "Change direction" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
streamGate = undefined

View file

@ -9,7 +9,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
@ -84,13 +83,12 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.PromptAdmitted, {
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: messageID,
prompt: Prompt.make({ text }),
delivery: "steer",
input: { type: "user", data: { text }, delivery: "steer" },
})
yield* events.publish(SessionEvent.PromptPromoted, {
yield* events.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: messageID,
})

View file

@ -137,7 +137,9 @@ test("Core reuses the canonical shared schemas", async () => {
[SessionV2.Info, Session.Info],
[SessionV2.ListAnchor, Session.ListAnchor],
[coreSessionInput.Delivery, SessionInput.Delivery],
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionInput.Message, SessionInput.Message],
[coreSessionInput.User, SessionInput.User],
[coreSessionInput.Synthetic, SessionInput.Synthetic],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],

View file

@ -156,6 +156,7 @@ describe("QuestionTool", () => {
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [
@ -202,6 +203,7 @@ describe("QuestionTool", () => {
})
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [],

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema, Tracer } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream, Tracer } from "effect"
import {
ATTR_OPENCODE_LINK_TYPE,
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
@ -21,6 +21,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
@ -306,6 +307,13 @@ describe("SubagentTool", () => {
return span
},
})
const events = yield* EventV2.Service
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, {
sessionID: parent.id,
@ -320,7 +328,10 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
yield* Effect.yieldNow
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
const database = yield* Database.Service
yield* SessionInput.promoteSteers(database.db, events, parent.id)
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)