feat(tui): improve session fork handling

This commit is contained in:
Dax Raad 2026-07-06 18:58:34 -04:00
commit 96717c1a8c
24 changed files with 335 additions and 387 deletions

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "992b24b9-f3e9-41f5-87a5-4917d1423169",
"id": "95328a41-789d-44de-9643-6ac6ecd6b4ec",
"prevIds": [
"96e9fe64-660f-4a73-9414-b38bb7eac290"
"992b24b9-f3e9-41f5-87a5-4917d1423169"
],
"ddl": [
{
@ -1166,6 +1166,26 @@
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_session_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_message_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
@ -2252,4 +2272,4 @@
}
],
"renames": []
}
}

View file

@ -46,5 +46,6 @@ export const migrations = (
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,39 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706223930_add-session-fork",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
yield* tx.run(`
UPDATE \`session\`
SET
\`parent_id\` = NULL,
\`fork_session_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
),
\`fork_message_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.from')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -196,6 +196,8 @@ export default {
\`project_id\` text NOT NULL,
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,

View file

@ -746,6 +746,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
@ -754,7 +755,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = Mime.detect(resolved.bytes)
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
@ -791,6 +792,25 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
const info = yield* fs.stat(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
if (info.type === "Directory") {
const entries = yield* fs.readDirectoryEntries(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
@ -800,7 +820,7 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
const bytes = yield* fs.readFile(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) }
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
function decodeDataURL(uri: string) {

View file

@ -17,6 +17,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
projectID: ProjectV2.ID.make(row.project_id),
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork: row.fork_session_id
? {
sessionID: SessionSchema.ID.make(row.fork_session_id),
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
}
: undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
? {

View file

@ -190,7 +190,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: event.data.parentID,
parent_id: null,
fork_session_id: event.data.parentID,
fork_message_id: event.data.from,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),

View file

@ -41,6 +41,26 @@ const textAttachment = (file: FileAttachment) =>
},
})
const directoryAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
content: [
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
]
.filter((line): line is string => line !== undefined)
.join("\n"),
metadata: {
attachment: {
source: file.source,
name: file.name,
description: file.description,
},
},
})
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
@ -157,6 +177,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
const files = message.files ?? []
return [
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment),
Message.make({
id: message.id,
role: "user",

View file

@ -29,6 +29,8 @@ export const SessionTable = sqliteTable(
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),

View file

@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -156,6 +157,39 @@ describe("DatabaseMigration", () => {
)
})
test("separates existing fork provenance from subagent hierarchy", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`)
yield* db.run(
sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`)
yield* db.run(
sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`,
)
yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration])
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`),
).toEqual({
parent_id: null,
fork_session_id: "ses_source",
fork_message_id: "msg_boundary",
})
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`),
).toEqual({
parent_id: null,
fork_session_id: null,
fork_message_id: null,
})
}),
)
})
test("renames instruction state without losing rows or durable updates", async () => {
await run(
Effect.gen(function* () {

View file

@ -206,7 +206,8 @@ describe("SessionV2.create", () => {
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
expect(forked.parentID).toBeUndefined()
expect(forkContext).toMatchObject([
{ type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id },
@ -264,6 +265,7 @@ describe("SessionV2.create", () => {
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } })

View file

@ -283,25 +283,27 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("rejects directories as file attachments", () =>
it.effect("materializes directories as directory attachments", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const uri = pathToFileURL(import.meta.dir).href
const error = yield* session
.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
resume: false,
})
.pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri,
message: `Attachment is not a file: ${uri}`,
const message = yield* session.prompt({
sessionID,
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
resume: false,
})
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
)

View file

@ -217,6 +217,35 @@ Recent work
])
})
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-directory"),
type: "user",
text: "Review this directory",
files: [directory],
time: { created },
}),
],
model,
)
expect(messages).toHaveLength(2)
expect(messages[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }],
metadata: { attachment: { source: directory.source, name: "src/" } },
})
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(