Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
38cc62a
feat(core): bound tool and admitted event payloads via session blobs
armancharan Jul 17, 2026
86f6d25
refactor(core): fold tool payload budget into Effect pipes
armancharan Jul 17, 2026
b449d84
refactor(core): polish bound payload Effect and admit paths
armancharan Jul 17, 2026
d6e8442
fix(core): re-export ToolPayload Hash/Body without type conflicts
armancharan Jul 17, 2026
81e2f87
fix(core): restore ToolPayload schema re-export pattern
armancharan Jul 17, 2026
cc1e385
test(core): verify tool payload hydrate and over-budget fail path
armancharan Jul 17, 2026
783e3e5
refactor(core): share tool payload persist binding in runner
armancharan Jul 17, 2026
9d356b2
test(core): decode event fixtures instead of casting
armancharan Jul 17, 2026
4c8df72
fix(core): omit undefined resultState on thin tool events
armancharan Jul 17, 2026
60e29b3
test(tui): lock payloadHash message and pending refetch
armancharan Jul 17, 2026
cb892f2
test(core): narrow with assert helpers for linear test bodies
armancharan Jul 17, 2026
8413694
Revert "test(core): narrow with assert helpers for linear test bodies"
armancharan Jul 17, 2026
17ac510
test(core): share asserted event type strings in payload tests
armancharan Jul 17, 2026
45662ab
test(core): narrow payload fixtures with Option.getOrThrowWith
armancharan Jul 17, 2026
1700efc
test(core): hoist UserInput aliases for Option liftPredicate narrows
armancharan Jul 18, 2026
135d2d8
refactor(tui): flatten admitted payloadHash pending refetch
armancharan Jul 18, 2026
b447bde
style(core): wrap ToolPayload.hash for prettier
armancharan Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,7 @@ export type SessionToolProgress = {
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
payloadHash?: string
}
}

Expand All @@ -1875,6 +1876,7 @@ export type SessionToolSuccess = {
result?: any
executed: boolean
resultState?: SessionMessageProviderState6
payloadHash?: string
}
}

Expand Down Expand Up @@ -2162,7 +2164,7 @@ export type SessionInputAdmitted = {
type: "session.input.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string; input: SessionPendingMessage }
data: { sessionID: string; inputID: string; input: SessionPendingMessage; payloadHash?: string }
}

export type SessionMessageInfo =
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260718010000_session_tool_payload",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_tool_payload\` (
\`session_id\` text NOT NULL,
\`hash\` text NOT NULL,
\`value\` text NOT NULL,
PRIMARY KEY(\`session_id\`, \`hash\`),
FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE cascade
);
`)
})
},
} satisfies DatabaseMigration.Migration
9 changes: 9 additions & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ export default {
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_tool_payload\` (
\`session_id\` text NOT NULL,
\`hash\` text NOT NULL,
\`value\` text NOT NULL,
PRIMARY KEY(\`session_id\`, \`hash\`),
FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE cascade
);
`)
yield* tx.run(`
CREATE TABLE \`session_share\` (
\`session_id\` text PRIMARY KEY,
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/session/pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable, SessionPendingTable } from "./sql"
import { ToolPayload } from "./tool-payload"

type DatabaseService = Database.Interface["db"]

Expand All @@ -31,12 +32,33 @@ const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const encodeMessage = Schema.encodeSync(Message)
const decodeMessage = Schema.decodeUnknownSync(Message)
const admittedEventType = Event.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()

/** Drop inline attachment bytes from the durable event projection; full body lives in the payload blob. */
export function stripAttachmentBytes(input: Message): Message {
if (input.type !== "user" || !input.data.files?.length) return input
return {
...input,
data: {
...input.data,
files: input.data.files.map((file) => ({ ...file, data: "" })),
},
}
}

function hasAttachmentBytes(input: Message): boolean {
if (input.type !== "user" || !input.data.files?.length) return false
return input.data.files.some((file) => file.data.length > 0)
}

const decodeJson = Schema.decodeUnknownSync(Schema.Json)

export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
Expand Down Expand Up @@ -126,15 +148,16 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
for (const row of rows) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
const input = decoded.value.payloadHash
? decodeMessage(yield* ToolPayload.loadJson(db, sessionID, decoded.value.payloadHash).pipe(Effect.orDie))
: decoded.value.input
const base = {
admittedSeq: row.seq,
id,
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
return input.type === "user" ? User.make({ ...base, ...input }) : Synthetic.make({ ...base, ...input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
Expand All @@ -157,11 +180,16 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
}
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
const thin = stripAttachmentBytes(request.input)
const payloadHash = hasAttachmentBytes(request.input)
? yield* ToolPayload.insertJson(db, request.sessionID, decodeJson(encodeMessage(request.input)))
: undefined
return yield* events
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
sessionID: request.sessionID,
input: request.input,
input: thin,
...(payloadHash !== undefined ? { payloadHash } : {}),
})
.pipe(
Effect.flatMap((event) => {
Expand Down
44 changes: 41 additions & 3 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import type { SessionSchema } from "./schema"
import { WorkspaceV2 } from "../workspace"
import { InstructionState } from "./instruction-state"
import { ToolPayload } from "./tool-payload"
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
Expand Down Expand Up @@ -318,6 +320,33 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
yield* InstructionState.rebuild(db, event.data.sessionID)
})

function hydrateToolPayload<
E extends {
readonly data: {
readonly sessionID: SessionSchema.ID
readonly payloadHash?: ToolPayload.Hash
readonly structured: Record<string, unknown>
readonly content: ToolPayload.Body["content"]
readonly result?: unknown
}
},
>(db: DatabaseService, event: E) {
return Effect.gen(function* () {
const payloadHash = event.data.payloadHash
if (!payloadHash) return event
const body = yield* ToolPayload.load(db, event.data.sessionID, payloadHash).pipe(Effect.orDie)
return {
...event,
data: {
...event.data,
structured: body.structured,
content: body.content,
...(body.result !== undefined ? { result: body.result } : {}),
},
}
})
}

function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
Expand Down Expand Up @@ -647,11 +676,16 @@ const layer = Layer.effectDiscard(
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const input = event.data.payloadHash
? Schema.decodeUnknownSync(SessionPending.Message)(
yield* ToolPayload.loadJson(db, event.data.sessionID, event.data.payloadHash).pipe(Effect.orDie),
)
: event.data.input
yield* SessionPending.projectAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
input: event.data.input,
input,
timeCreated: event.created,
})
}),
Expand Down Expand Up @@ -697,8 +731,12 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Progress, (event) =>
hydrateToolPayload(db, event).pipe(Effect.flatMap((hydrated) => run(db, hydrated))),
)
yield* events.project(SessionEvent.Tool.Success, (event) =>
hydrateToolPayload(db, event).pipe(Effect.flatMap((hydrated) => run(db, hydrated))),
)
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
Expand Down
29 changes: 23 additions & 6 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { ToolPayload } from "../tool-payload"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
Expand Down Expand Up @@ -116,6 +117,7 @@ const layer = Layer.effect(
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
const startSnapshot = yield* snapshots.capture()
const persistToolPayload = (body: ToolPayload.Body) => ToolPayload.insert(db, session.id, body)
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
Expand All @@ -125,6 +127,7 @@ const layer = Layer.effect(
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
snapshot: startSnapshot,
assistantMessageID,
persistToolPayload,
})
const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave
Expand Down Expand Up @@ -161,12 +164,26 @@ const layer = Layer.effect(
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
Effect.gen(function* () {
const body: ToolPayload.Body = {
structured: { ...update.structured },
content: [...update.content],
}
const payloadHash = yield* persistToolPayload(body)
const thin = ToolPayload.preview(body)
const data = {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: thin.structured,
content: thin.content,
payloadHash,
}
// Thin progress frames should always fit; over-budget is a defect.
yield* ToolPayload.assertEventDataBudget(data).pipe(
Effect.andThen(events.publish(SessionEvent.Tool.Progress, data)),
Effect.orDie,
)
}),
),
}),
Expand Down
39 changes: 32 additions & 7 deletions packages/core/src/session/runner/publish-llm-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { SessionUsage } from "../usage"
import { ToolPayload } from "../tool-payload"

type Input = {
readonly sessionID: SessionSchema.ID
Expand All @@ -18,6 +19,8 @@ type Input = {
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly assistantMessageID?: SessionMessage.ID
/** Persist full tool settlement beside the durable log; required for thin tool events. */
readonly persistToolPayload: (body: ToolPayload.Body) => Effect.Effect<ToolPayload.Hash>
}

const record = (value: unknown): Record<string, unknown> =>
Expand Down Expand Up @@ -378,19 +381,40 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
error: result.error,
result: event.result,
executed,
resultState,
...(resultState !== undefined ? { resultState } : {}),
})
return
}
yield* events.publish(SessionEvent.Tool.Success, {
const body: ToolPayload.Body = {
structured: result.structured,
content: result.content,
...(executed ? { result: event.result } : {}),
}
const payloadHash = yield* input.persistToolPayload(body)
const thin = ToolPayload.preview(body)
const data = {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
...(executed ? { result: event.result } : {}),
structured: thin.structured,
content: thin.content,
executed,
resultState,
})
payloadHash,
...(resultState !== undefined ? { resultState } : {}),
}
yield* ToolPayload.assertEventDataBudget(data).pipe(
Effect.andThen(events.publish(SessionEvent.Tool.Success, data)),
Effect.catchTag("ToolPayload.OverBudgetError", (error) =>
events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: error.message },
executed,
...(resultState !== undefined ? { resultState } : {}),
}),
),
)
return
}
case "tool-error": {
Expand All @@ -400,6 +424,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return yield* Effect.die(new Error(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool error: ${event.id}`))
tool.settled = true
const resultState = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
Expand All @@ -409,7 +434,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
? { type: "tool.unknown", message: event.message }
: { type: "tool.execution", message: event.message },
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
...(resultState !== undefined ? { resultState } : {}),
})
return
}
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { ToolPayload } from "@opencode-ai/schema/tool-payload"
import type { Session } from "@opencode-ai/schema/session"
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
Expand Down Expand Up @@ -172,6 +173,20 @@ export const InstructionBlobTable = sqliteTable("instruction_blob", {
value: text({ mode: "json" }).$type<Schema.Json>(),
})

/** Session-durable tool settlement bodies for thin tool events. */
export const SessionToolPayloadTable = sqliteTable(
"session_tool_payload",
{
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
hash: text().$type<ToolPayload.Hash>().notNull(),
value: text({ mode: "json" }).$type<Schema.Json>().notNull(),
},
(table) => [primaryKey({ columns: [table.session_id, table.hash] })],
)

export const InstructionStateTable = sqliteTable("instruction_state", {
session_id: text()
.$type<SessionSchema.ID>()
Expand Down
Loading
Loading