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

@ -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(