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,
}),
]