Skip to content
Merged
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
6 changes: 4 additions & 2 deletions packages/opencode/src/cli/cmd/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type TargetInput = {

type Target = {
baseID?: string
title?: string
}

function suffix(dir?: string) {
Expand Down Expand Up @@ -64,7 +65,8 @@ export async function resolveRemoteTarget(input: TargetInput): Promise<Target> {
const result = await input.sdk.session.list({ roots: true }, { throwOnError: true }).catch((error) => {
throw new Error(`Failed to resolve remote continue target${suffix(input.directory)}: ${message(error)}`)
})
const baseID = result.data?.find((item) => !item.parentID)?.id
const item = result.data?.find((item) => !item.parentID)
const baseID = item?.id
if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`)
return { baseID }
return { baseID, title: item?.title }
}
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const AttachCommand = cmd({
UI.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
await resolveRemoteTarget({
const target = await resolveRemoteTarget({
sdk,
directory,
continue: args.continue,
Expand All @@ -85,6 +85,13 @@ export const AttachCommand = cmd({
UI.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
if (args.continue && target.baseID) {
UI.println(
UI.Style.TEXT_INFO_BOLD + "Continuing remote session" + UI.Style.TEXT_NORMAL,
target.title ?? target.baseID,
UI.Style.TEXT_DIM + `(${target.baseID})` + UI.Style.TEXT_NORMAL,
)
}
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
Expand Down
276 changes: 178 additions & 98 deletions packages/opencode/test/cli/remote-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("remote preflight", () => {
expect(tui).toHaveBeenCalledTimes(1)
})

test("attach fails clearly when the remote directory does not match", () => {
test("attach fails clearly when the remote directory does not match", async () => {
stopExit()
mockAttach()
const err = spyOn(UI, "error").mockImplementation(() => {})
Expand All @@ -87,24 +87,30 @@ describe("remote preflight", () => {
}),
)

return expect(
AttachCommand.handler({
_: [],
$0: "opencode",
url: "http://remote.test",
dir: "/srv/app",
continue: false,
session: undefined,
fork: false,
password: undefined,
}),
).rejects.toBe(exit)
let thrown: unknown
try {
await Promise.resolve(
AttachCommand.handler({
_: [],
$0: "opencode",
url: "http://remote.test",
dir: "/srv/app",
continue: false,
session: undefined,
fork: false,
password: undefined,
}),
)
} catch (error) {
thrown = error
}

expect(thrown).toBe(exit)
expect(err).toHaveBeenCalled()
expect(tui).not.toHaveBeenCalled()
})

test("attach fails before starting tui when the remote session is missing", () => {
test("attach fails before starting tui when the remote session is missing", async () => {
stopExit()
mockAttach()
const err = spyOn(UI, "error").mockImplementation(() => {})
Expand All @@ -129,19 +135,25 @@ describe("remote preflight", () => {
}),
)

return expect(
AttachCommand.handler({
_: [],
$0: "opencode",
url: "http://remote.test",
dir: "/srv/app",
continue: false,
session: "missing",
fork: false,
password: undefined,
}),
).rejects.toBe(exit)
let thrown: unknown
try {
await Promise.resolve(
AttachCommand.handler({
_: [],
$0: "opencode",
url: "http://remote.test",
dir: "/srv/app",
continue: false,
session: "missing",
fork: false,
password: undefined,
}),
)
} catch (error) {
thrown = error
}

expect(thrown).toBe(exit)
expect(get).toHaveBeenCalledWith({ sessionID: "missing" }, { throwOnError: true })
expect(err).toHaveBeenCalledWith(expect.stringContaining('Remote session "missing"'))
expect(tui).not.toHaveBeenCalled()
Expand Down Expand Up @@ -187,7 +199,57 @@ describe("remote preflight", () => {
expect(tui).toHaveBeenCalledTimes(1)
})

test("run --attach fails before creating a session when the remote is unreachable", () => {
test("attach announces the remote continue target before starting tui", async () => {
mockAttach()
const info = spyOn(UI, "println").mockImplementation(() => {})
const list = mock(async () => ({
data: [
{
id: "sess_123",
title: "Remote draft",
parentID: undefined,
},
],
}))
const tui = spyOn(App, "tui").mockResolvedValue()
spyOn(SDK, "createOpencodeClient").mockReturnValue(
client({
path: {
get: mock(async () => ({
data: {
home: "/home/me",
state: "/state",
config: "/config",
worktree: "/srv/app",
directory: "/srv/app",
},
})),
},
session: { list },
}),
)

await AttachCommand.handler({
_: [],
$0: "opencode",
url: "http://remote.test",
dir: "/srv/app",
continue: true,
session: undefined,
fork: false,
password: undefined,
})

expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true })
expect(info).toHaveBeenCalledWith(
expect.stringContaining("Continuing remote session"),
expect.stringContaining("Remote draft"),
expect.stringContaining("sess_123"),
)
expect(tui).toHaveBeenCalledTimes(1)
})

test("run --attach fails before creating a session when the remote is unreachable", async () => {
stopExit()
const err = spyOn(UI, "error").mockImplementation(() => {})
const create = mock(async () => {
Expand All @@ -213,29 +275,35 @@ describe("remote preflight", () => {
})

try {
return expect(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: false,
session: undefined,
fork: false,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: undefined,
port: undefined,
variant: undefined,
thinking: false,
}),
).rejects.toBe(exit)
let thrown: unknown
try {
await Promise.resolve(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: false,
session: undefined,
fork: false,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: undefined,
port: undefined,
variant: undefined,
thinking: false,
}),
)
} catch (error) {
thrown = error
}
expect(thrown).toBe(exit)
} finally {
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
Expand All @@ -245,7 +313,7 @@ describe("remote preflight", () => {
expect(create).not.toHaveBeenCalled()
})

test("run --attach fails before creating a session when the remote continue target is missing", () => {
test("run --attach fails before creating a session when the remote continue target is missing", async () => {
stopExit()
const err = spyOn(UI, "error").mockImplementation(() => {})
const create = mock(async () => {
Expand Down Expand Up @@ -281,29 +349,35 @@ describe("remote preflight", () => {
})

try {
return expect(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: true,
session: undefined,
fork: false,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: "/srv/app",
port: undefined,
variant: undefined,
thinking: false,
}),
).rejects.toBe(exit)
let thrown: unknown
try {
await Promise.resolve(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: true,
session: undefined,
fork: false,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: "/srv/app",
port: undefined,
variant: undefined,
thinking: false,
}),
)
} catch (error) {
thrown = error
}
expect(thrown).toBe(exit)
} finally {
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
Expand All @@ -314,7 +388,7 @@ describe("remote preflight", () => {
expect(create).not.toHaveBeenCalled()
})

test("run --attach fails before forking when the remote fork base is missing", () => {
test("run --attach fails before forking when the remote fork base is missing", async () => {
stopExit()
const err = spyOn(UI, "error").mockImplementation(() => {})
const get = mock(async () => {
Expand Down Expand Up @@ -350,29 +424,35 @@ describe("remote preflight", () => {
})

try {
return expect(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: false,
session: "missing",
fork: true,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: "/srv/app",
port: undefined,
variant: undefined,
thinking: false,
}),
).rejects.toBe(exit)
let thrown: unknown
try {
await Promise.resolve(
RunCommand.handler({
_: [],
$0: "opencode",
message: ["hi"],
command: undefined,
continue: false,
session: "missing",
fork: true,
share: false,
model: undefined,
agent: undefined,
format: "default",
file: undefined,
title: undefined,
attach: "http://remote.test",
password: undefined,
dir: "/srv/app",
port: undefined,
variant: undefined,
thinking: false,
}),
)
} catch (error) {
thrown = error
}
expect(thrown).toBe(exit)
} finally {
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
else delete (process.stdin as { isTTY?: boolean }).isTTY
Expand Down