refactor(schema): session shell payloads and event prefix restore (#35229)
This commit is contained in:
parent
64e4f6f91b
commit
650d774372
49 changed files with 1521 additions and 1200 deletions
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 } }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"]])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue