feat(cli): extend run command with port, attach, session-id, on-complete, and json options

Implement all 5 CLI extension options for external orchestration:

- --port <port>: Start server on port, or attach if port occupied
- --attach <url>: Connect to existing opencode server
- --session-id <id>: Resume existing session instead of creating new
- --on-complete <command>: Execute shell command with env vars on completion
- --json: Output structured RunResult JSON to stdout

Refactor runner.ts into focused modules:
- agent-resolver.ts: Agent resolution logic
- server-connection.ts: Server connection management
- session-resolver.ts: Session create/resume with retry
- json-output.ts: Stdout redirect + JSON emission
- on-complete-hook.ts: Shell command execution with env vars

Fixes #1586
This commit is contained in:
YeonGyu-Kim
2026-02-07 17:26:33 +09:00
parent 1c0b41aa65
commit e343e625c7
15 changed files with 1284 additions and 179 deletions
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import { resolveSession } from "./session-resolver"
import type { OpencodeClient } from "./types"
const createMockClient = (overrides: {
getResult?: { error?: unknown; data?: { id: string } }
createResults?: Array<{ error?: unknown; data?: { id: string } }>
} = {}): OpencodeClient => {
const { getResult, createResults = [] } = overrides
let createCallIndex = 0
return {
session: {
get: mock((opts: { path: { id: string } }) =>
Promise.resolve(getResult ?? { data: { id: opts.path.id } })
),
create: mock(() => {
const result =
createResults[createCallIndex] ?? { data: { id: "new-session-id" } }
createCallIndex++
return Promise.resolve(result)
}),
},
} as unknown as OpencodeClient
}
describe("resolveSession", () => {
beforeEach(() => {
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
})
it("returns provided session ID when session exists", async () => {
// given
const sessionId = "existing-session-id"
const mockClient = createMockClient({
getResult: { data: { id: sessionId } },
})
// when
const result = await resolveSession({ client: mockClient, sessionId })
// then
expect(result).toBe(sessionId)
expect(mockClient.session.get).toHaveBeenCalledWith({
path: { id: sessionId },
})
expect(mockClient.session.create).not.toHaveBeenCalled()
})
it("throws error when provided session ID not found", async () => {
// given
const sessionId = "non-existent-session-id"
const mockClient = createMockClient({
getResult: { error: { message: "Session not found" } },
})
// when
const result = resolveSession({ client: mockClient, sessionId })
// then
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
expect(mockClient.session.get).toHaveBeenCalledWith({
path: { id: sessionId },
})
expect(mockClient.session.create).not.toHaveBeenCalled()
})
it("creates new session when no session ID provided", async () => {
// given
const mockClient = createMockClient({
createResults: [{ data: { id: "new-session-id" } }],
})
// when
const result = await resolveSession({ client: mockClient })
// then
expect(result).toBe("new-session-id")
expect(mockClient.session.create).toHaveBeenCalledWith({
body: { title: "oh-my-opencode run" },
})
expect(mockClient.session.get).not.toHaveBeenCalled()
})
it("retries session creation on failure", async () => {
// given
const mockClient = createMockClient({
createResults: [
{ error: { message: "Network error" } },
{ data: { id: "retried-session-id" } },
],
})
// when
const result = await resolveSession({ client: mockClient })
// then
expect(result).toBe("retried-session-id")
expect(mockClient.session.create).toHaveBeenCalledTimes(2)
expect(mockClient.session.create).toHaveBeenCalledWith({
body: { title: "oh-my-opencode run" },
})
})
it("throws after all retries exhausted", async () => {
// given
const mockClient = createMockClient({
createResults: [
{ error: { message: "Error 1" } },
{ error: { message: "Error 2" } },
{ error: { message: "Error 3" } },
],
})
// when
const result = resolveSession({ client: mockClient })
// then
await expect(result).rejects.toThrow("Failed to create session after all retries")
expect(mockClient.session.create).toHaveBeenCalledTimes(3)
})
it("session creation returns no ID", async () => {
// given
const mockClient = createMockClient({
createResults: [
{ data: undefined },
{ data: undefined },
{ data: undefined },
],
})
// when
const result = resolveSession({ client: mockClient })
// then
await expect(result).rejects.toThrow("Failed to create session after all retries")
expect(mockClient.session.create).toHaveBeenCalledTimes(3)
})
})