mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge 31d61ca846 into 83397ebde2
This commit is contained in:
commit
a0467b069d
4 changed files with 334 additions and 8 deletions
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
258
packages/opencode/test/session/fork.test.ts
Normal file
258
packages/opencode/test/session/fork.test.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue