This commit is contained in:
Nils Riedemann 2025-12-23 15:42:20 +08:00 committed by GitHub
commit a0467b069d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 334 additions and 8 deletions

View file

@ -5,11 +5,14 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { EOL } from "os"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../server/server"
import { tui } from "./tui/app"
export const SessionCommand = cmd({
command: "session",
describe: "manage sessions",
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionForkCommand).demandCommand(),
async handler() {},
})
@ -104,3 +107,57 @@ function formatSessionJSON(sessions: Session.Info[]): string {
}))
return JSON.stringify(jsonData, null, 2)
}
export const SessionForkCommand = cmd({
command: "fork",
describe: "fork a session to explore parallel conversation branches",
builder: (yargs: Argv) => {
return yargs
.option("session", {
alias: "s",
describe: "session ID to fork",
type: "string",
demandOption: true,
})
.option("message", {
alias: "m",
describe: "fork up to this message ID",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
const result = await sdk.session
.fork({
path: { id: args.session },
body: { messageID: args.message },
})
.catch((error) => {
server.stop()
const errorMessage = error.message || String(error)
UI.error(`Failed to fork session: ${errorMessage}`)
process.exit(1)
})
if (!result.data) {
server.stop()
UI.error("Failed to fork session")
process.exit(1)
}
const forkedSessionID = result.data.id
UI.println(UI.Style.TEXT_INFO_BOLD + `Forked session: ${forkedSessionID}`)
UI.println()
await tui({
url: `http://${server.hostname}:${server.port}`,
args: { sessionID: forkedSessionID },
})
server.stop()
})
},
})

View file

@ -103,6 +103,16 @@ export function DialogMessage(props: {
dialog.clear()
},
},
{
title: "Copy Fork Command",
value: "session.copy-fork-command",
description: "to fork in a new terminal",
onSelect: async (dialog) => {
const command = `opencode session fork --session ${props.sessionID} --message ${props.messageID}`
await Clipboard.copy(command)
dialog.clear()
},
},
]}
/>
)

View file

@ -143,23 +143,24 @@ export namespace Session {
messageID: Identifier.schema("message").optional(),
}),
async (input) => {
const originalSession = await get(input.sessionID)
const session = await createNext({
directory: Instance.directory,
directory: originalSession.directory,
parentID: input.sessionID,
})
const msgs = await messages({ sessionID: input.sessionID })
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const cloned = await updateMessage({
if (input.messageID && msg.info.id > input.messageID) break
await Storage.write(["message", session.id, msg.info.id], {
...msg.info,
sessionID: session.id,
id: Identifier.ascending("message"),
})
for (const part of msg.parts) {
await updatePart({
await Storage.write(["part", msg.info.id, part.id], {
...part,
id: Identifier.ascending("part"),
messageID: cloned.id,
sessionID: session.id,
})
}

View file

@ -0,0 +1,258 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { Identifier } from "../../src/id/id"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
describe("Session.fork", () => {
test("should fork entire session without message ID", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Original Session",
})
await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
})
expect(forkedSession).toBeDefined()
expect(forkedSession.id).not.toBe(originalSession.id)
expect(forkedSession.directory).toBe(originalSession.directory)
expect(forkedSession.projectID).toBe(originalSession.projectID)
const originalMessages = await Session.messages({ sessionID: originalSession.id })
const forkedMessages = await Session.messages({ sessionID: forkedSession.id })
expect(forkedMessages.length).toBe(originalMessages.length)
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
test("should fork session up to specific message", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Session with Multiple Messages",
})
const msg1 = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
messageID: msg1.id,
})
const originalMessages = await Session.messages({ sessionID: originalSession.id })
const forkedMessages = await Session.messages({ sessionID: forkedSession.id })
expect(originalMessages.length).toBe(3)
expect(forkedMessages.length).toBe(1)
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
test("should create independent forked session", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Original Independent",
})
await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
})
const newMsgInFork = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: forkedSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
const originalMessages = await Session.messages({ sessionID: originalSession.id })
const forkedMessages = await Session.messages({ sessionID: forkedSession.id })
expect(originalMessages.length).toBe(1)
expect(forkedMessages.length).toBe(2)
const msgExistsInFork = forkedMessages.some((m) => m.info.id === newMsgInFork.id)
const msgExistsInOriginal = originalMessages.some((m) => m.info.id === newMsgInFork.id)
expect(msgExistsInFork).toBe(true)
expect(msgExistsInOriginal).toBe(false)
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
test("should copy message parts when forking", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Session with Parts",
})
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: originalSession.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "opencode", modelID: "claude-3-5-sonnet-20241022" },
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: originalSession.id,
type: "text",
text: "Test message content",
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
})
const forkedMessages = await Session.messages({ sessionID: forkedSession.id })
expect(forkedMessages.length).toBe(1)
expect(forkedMessages[0].parts.length).toBe(1)
expect(forkedMessages[0].parts[0].type).toBe("text")
if (forkedMessages[0].parts[0].type === "text") {
expect(forkedMessages[0].parts[0].text).toBe("Test message content")
}
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
test("should handle empty session fork", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Empty Session",
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
})
expect(forkedSession).toBeDefined()
expect(forkedSession.id).not.toBe(originalSession.id)
const forkedMessages = await Session.messages({ sessionID: forkedSession.id })
expect(forkedMessages.length).toBe(0)
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
test("should throw error for non-existent session", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const nonExistentSessionID = "session_nonexistent123"
try {
await Session.fork({
sessionID: nonExistentSessionID,
})
expect(true).toBe(false)
} catch (error) {
expect(error).toBeDefined()
}
},
})
})
test("forked session should have different ID but same project", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const originalSession = await Session.create({
title: "Project Test",
})
const forkedSession = await Session.fork({
sessionID: originalSession.id,
})
expect(forkedSession.id).not.toBe(originalSession.id)
expect(forkedSession.projectID).toBe(originalSession.projectID)
expect(forkedSession.directory).toBe(originalSession.directory)
expect(forkedSession.version).toBe(originalSession.version)
await Session.remove(originalSession.id)
await Session.remove(forkedSession.id)
},
})
})
})