chore(observability): merge v2
This commit is contained in:
commit
f4e661f874
276 changed files with 17001 additions and 3755 deletions
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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"`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue