Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 58 additions & 1 deletion packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {},
})

Expand Down Expand Up @@ -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()
})
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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()
},
},
]}
/>
)
Expand Down
15 changes: 8 additions & 7 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,23 +141,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,
})
}
Expand Down
258 changes: 258 additions & 0 deletions packages/opencode/test/session/fork.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})