feat(core): generalize session input inbox (#36005)

This commit is contained in:
Kit Langton 2026-07-08 22:07:45 -04:00 committed by GitHub
commit 984cab7938
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1590 additions and 767 deletions

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

@ -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

@ -18,7 +18,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"
@ -179,7 +178,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,
})
@ -200,8 +199,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

@ -29,7 +29,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"
@ -72,6 +71,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
@ -86,6 +86,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
@ -389,8 +390,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* () {
@ -427,6 +427,7 @@ const setup = Effect.gen(function* () {
responses = undefined
streamFailure = undefined
responseStream = undefined
responseStreams = undefined
streamGate = undefined
streamStarted = undefined
toolExecutionGate = undefined
@ -774,7 +775,7 @@ describe("SessionRunnerLLM", () => {
const message = yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Run automatically" }),
text: "Run automatically",
})
yield* session.wait(sessionID)
@ -785,6 +786,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
@ -813,7 +842,7 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "First" }),
text: "First",
resume: false,
})
@ -832,7 +861,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)
@ -1099,7 +1128,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 = []
@ -1120,7 +1149,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 = []
@ -1409,9 +1438,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,
})
@ -1423,6 +1453,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({
@ -1451,7 +1482,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,
})
@ -2189,7 +2220,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
@ -2221,7 +2252,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)
@ -2250,7 +2281,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)
@ -2284,7 +2315,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" })
@ -2315,8 +2346,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
@ -2335,7 +2366,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,
})
@ -2362,16 +2393,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
@ -2384,12 +2416,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",
])
}),
@ -2406,8 +2440,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
@ -2433,7 +2467,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)
@ -2592,7 +2626,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,
})
@ -2611,7 +2645,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)
@ -2631,7 +2665,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,
)
@ -2651,7 +2685,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,
})
@ -2686,12 +2720,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,
})
@ -3253,7 +3287,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

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@ -16,6 +16,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"
@ -263,6 +264,13 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
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,
@ -277,7 +285,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"`)