refactor(schema): session shell payloads and event prefix restore (#35229)

This commit is contained in:
Kit Langton 2026-07-03 17:30:25 -04:00 committed by GitHub
commit 650d774372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1521 additions and 1200 deletions

View file

@ -43,5 +43,6 @@ export const migrations = (
import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703190000_reset_v2_shell_event_payloads",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -41,8 +41,8 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill"
import { Job } from "./job"
import { CommandV2 } from "./command"
import { Identifier } from "./util/identifier"
import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
export const RevertState = Revert.State
@ -272,19 +272,6 @@ const layer = Layer.effect(
),
)
// Session shell is user-initiated and synchronous at the API boundary, while
// the Location shell service owns process lifecycle and file-backed output.
const runShellCommand = (command: string, cwd: string) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const info = yield* shell.create({ command, cwd })
yield* shell.wait(info.id)
const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES })
return output.output || "(no output)"
}).pipe(
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@ -550,23 +537,38 @@ const layer = Layer.effect(
Effect.gen(function* () {
activeShells.add(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const callID = Identifier.ascending()
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
callID,
command: input.command,
shell: started,
},
{ id: input.id },
)
const output = yield* runShellCommand(input.command, session.location.directory).pipe(
Effect.provide(locations.get(session.location)),
)
const completed = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
})
.pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
callID,
output,
shell: completed.shell,
output: completed.output,
})
}).pipe(
Effect.ensuring(
@ -706,6 +708,26 @@ const layer = Layer.effect(
}),
)
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,

View file

@ -129,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
return ""
}

View file

@ -8,21 +8,25 @@ export type MemoryState = {
}
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
}
export function memory(state: MemoryState): Adapter {
const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
return {
getCurrentAssistant() {
@ -41,12 +45,11 @@ export function memory(state: MemoryState): Adapter {
return assistant?.type === "assistant" ? assistant : undefined
})
},
getCurrentShell(callID) {
getShell(shellID) {
return Effect.sync(() => {
const index = activeShellIndex(callID)
if (index < 0) return
const shell = state.messages[index]
return shell?.type === "shell" ? shell : undefined
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shell.id === shellID
})
})
},
updateAssistant(assistant) {
@ -60,7 +63,7 @@ export function memory(state: MemoryState): Adapter {
},
updateShell(shell) {
return Effect.sync(() => {
const index = activeShellIndex(shell.callID)
const index = shellIndex(shell.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
@ -100,7 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"agent.selected": (event) => {
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@ -111,7 +114,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"model.selected": (event) => {
"session.model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@ -123,11 +126,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.moved": () => Effect.void,
renamed: () => Effect.void,
forked: () => Effect.void,
"prompt.promoted": () => Effect.void,
"prompt.admitted": () => Effect.void,
"execution.settled": () => Effect.void,
"session.renamed": () => Effect.void,
"session.forked": () => Effect.void,
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.settled": () => Effect.void,
"session.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
@ -137,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created },
}),
),
synthetic: (event) => {
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
@ -150,7 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"skill.activated": (event) => {
"session.skill.activated": (event) => {
return adapter.appendMessage(
SessionMessage.Skill.make({
id: SessionMessage.ID.fromEvent(event.id),
@ -161,25 +164,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"shell.started": (event) => {
"session.shell.started": (event) => {
return adapter.appendMessage(
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
callID: event.data.callID,
command: event.data.command,
output: "",
shell: event.data.shell,
time: { created: event.created },
}),
)
},
"shell.ended": (event) => {
"session.shell.ended": (event) => {
return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
const currentShell = yield* adapter.getShell(event.data.shell.id)
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.shell = castDraft(event.data.shell)
draft.output = event.data.output
draft.time.completed = event.created
}),
@ -187,7 +189,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"step.started": (event) => {
"session.step.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
@ -210,7 +212,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"step.ended": (event) => {
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = event.data.finish
@ -224,33 +226,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"step.failed": (event) => {
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = "error"
draft.error = event.data.error
})
},
"text.started": (event) => {
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
})
},
"text.delta": (event) => {
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
})
},
"text.ended": (event) => {
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
})
},
"tool.input.started": (event) => {
"session.tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@ -265,14 +267,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"tool.input.delta": () => Effect.void,
"tool.input.ended": (event) => {
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "pending") match.state.input = event.data.text
})
},
"tool.called": (event) => {
"session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
@ -289,7 +291,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.progress": (event) => {
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@ -298,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.success": (event) => {
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@ -321,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.failed": (event) => {
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
@ -344,7 +346,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"reasoning.started": (event) => {
"session.reasoning.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@ -359,13 +361,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"reasoning.delta": (event) => {
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
})
},
"reasoning.ended": (event) => {
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
@ -375,10 +377,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
retried: () => Effect.void,
"compaction.started": () => Effect.void,
"compaction.delta": () => Effect.void,
"compaction.ended": (event) => {
"session.retried": () => Effect.void,
"session.compaction.started": () => Effect.void,
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
@ -391,9 +393,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"revert.staged": () => Effect.void,
"revert.cleared": () => Effect.void,
"revert.committed": () => Effect.void,
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
})
})
}

View file

@ -393,18 +393,25 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "assistant" ? message : undefined
})
},
getCurrentShell(callID) {
getShell(shellID) {
return Effect.gen(function* () {
const rows = yield* db
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.all()
.limit(1)
.get()
.pipe(Effect.orDie)
return rows
.map(decodeRow)
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
if (!row) return
const message = decodeRow(row)
return message.type === "shell" ? message : undefined
})
},
updateAssistant: updateMessage,

View file

@ -139,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.command}\n\n${message.output}`,
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
metadata: message.metadata,
}),
]

View file

@ -213,7 +213,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({
type: "forked",
type: "session.forked",
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
@ -378,8 +378,8 @@ describe("SessionV2.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "prompt.promoted" },
{ durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.prompt.promoted" },
])
}),
)
@ -494,8 +494,9 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "echo hello" })
expect(shell?.output).toContain("hello")
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
}),
),
@ -513,7 +514,8 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "false" })
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined()
}),
),
@ -529,7 +531,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "agent.selected", data: { agent: "plan" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
}),
)
@ -562,7 +564,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "model.selected", data: { model } }])
).toMatchObject([{ type: "session.model.selected", data: { model } }])
}),
)

View file

@ -40,14 +40,14 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "renamed" })
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* events.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["renamed", "log.synced"])
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}),
)
@ -64,7 +64,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" })
const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => item.type)).toEqual(["log.synced", "renamed"])
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
}),
)
@ -137,7 +137,7 @@ describe("SessionV2 watermarks", () => {
const events = yield* EventV2.Service
const first = yield* session.create({ location })
const second = yield* session.create({ location })
yield* session.rename({ sessionID: first.id, title: "renamed" })
yield* session.rename({ sessionID: first.id, title: "session.renamed" })
const page = yield* session.list()
const sequences = yield* events.sequences([first.id, second.id])

View file

@ -19,6 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
SessionContextCheckpointTable,
SessionInputTable,
@ -257,15 +258,32 @@ describe("SessionProjector", () => {
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
callID: "shell-1",
command: "pwd",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: {},
time: { started: 0 },
}),
})
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID,
callID: "shell-1",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 1 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
@ -320,7 +338,8 @@ describe("SessionProjector", () => {
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
shell: { command: "pwd", status: "exited", exit: 0 },
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({

View file

@ -256,16 +256,16 @@ describe("SessionV2.prompt", () => {
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "prompt.admitted"],
[1, "prompt.admitted"],
[2, "prompt.promoted"],
[3, "prompt.promoted"],
[0, "session.prompt.admitted"],
[1, "session.prompt.admitted"],
[2, "session.prompt.promoted"],
[3, "session.prompt.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, "prompt.admitted"]])
).toEqual([[1, "session.prompt.admitted"]])
}),
)

View file

@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@ -87,9 +88,18 @@ describe("toLLMMessages", () => {
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_test.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 0 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
}),
SessionMessage.Compaction.make({

View file

@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"prompt.admitted.1",
"prompt.promoted.1",
"step.started.1",
"text.started.1",
"text.ended.1",
"step.ended.1",
"session.prompt.admitted.1",
"session.prompt.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
"session.step.ended.1",
])
}),
)

View file

@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success?.data).toHaveProperty("result")
})
@ -110,8 +110,8 @@ test("binary failure emits no success event", async () => {
}),
),
)
expect(published.some((event) => event.type === "tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "tool.failed.1")).toBe(true)
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {

View file

@ -2985,7 +2985,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("step.failed.1")
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
yield* session.interrupt(sessionID)
}),
)
@ -3029,8 +3029,8 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("step.failed.1")
expect(eventTypes).not.toContain("step.ended.1")
expect(eventTypes).toContain("session.step.failed.1")
expect(eventTypes).not.toContain("session.step.ended.1")
}),
)