Merge pull request #1590 from code-yeongyu/feat/run-cli-extensions
feat(cli): extend run command with port, attach, session-id, on-complete, and json options
This commit is contained in:
commit
755a3a94c8
@ -69,11 +69,21 @@ program
|
|||||||
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
|
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
|
||||||
.option("-d, --directory <path>", "Working directory")
|
.option("-d, --directory <path>", "Working directory")
|
||||||
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
|
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
|
||||||
|
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
|
||||||
|
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||||
|
.option("--on-complete <command>", "Shell command to run after completion")
|
||||||
|
.option("--json", "Output structured JSON result to stdout")
|
||||||
|
.option("--session-id <id>", "Resume existing session instead of creating new one")
|
||||||
.addHelpText("after", `
|
.addHelpText("after", `
|
||||||
Examples:
|
Examples:
|
||||||
$ bunx oh-my-opencode run "Fix the bug in index.ts"
|
$ bunx oh-my-opencode run "Fix the bug in index.ts"
|
||||||
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
|
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
|
||||||
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
|
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
|
||||||
|
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
|
||||||
|
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
|
||||||
|
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
|
||||||
|
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
|
||||||
|
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
|
||||||
|
|
||||||
Agent resolution order:
|
Agent resolution order:
|
||||||
1) --agent flag
|
1) --agent flag
|
||||||
@ -89,11 +99,20 @@ Unlike 'opencode run', this command waits until:
|
|||||||
- All child sessions (background tasks) are idle
|
- All child sessions (background tasks) are idle
|
||||||
`)
|
`)
|
||||||
.action(async (message: string, options) => {
|
.action(async (message: string, options) => {
|
||||||
|
if (options.port && options.attach) {
|
||||||
|
console.error("Error: --port and --attach are mutually exclusive")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
const runOptions: RunOptions = {
|
const runOptions: RunOptions = {
|
||||||
message,
|
message,
|
||||||
agent: options.agent,
|
agent: options.agent,
|
||||||
directory: options.directory,
|
directory: options.directory,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
|
port: options.port,
|
||||||
|
attach: options.attach,
|
||||||
|
onComplete: options.onComplete,
|
||||||
|
json: options.json ?? false,
|
||||||
|
sessionId: options.sessionId,
|
||||||
}
|
}
|
||||||
const exitCode = await run(runOptions)
|
const exitCode = await run(runOptions)
|
||||||
process.exit(exitCode)
|
process.exit(exitCode)
|
||||||
|
|||||||
69
src/cli/run/agent-resolver.ts
Normal file
69
src/cli/run/agent-resolver.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import pc from "picocolors"
|
||||||
|
import type { RunOptions } from "./types"
|
||||||
|
import type { OhMyOpenCodeConfig } from "../../config"
|
||||||
|
|
||||||
|
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
|
||||||
|
const DEFAULT_AGENT = "sisyphus"
|
||||||
|
|
||||||
|
type EnvVars = Record<string, string | undefined>
|
||||||
|
|
||||||
|
const normalizeAgentName = (agent?: string): string | undefined => {
|
||||||
|
if (!agent) return undefined
|
||||||
|
const trimmed = agent.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const lowered = trimmed.toLowerCase()
|
||||||
|
const coreMatch = CORE_AGENT_ORDER.find((name) => name.toLowerCase() === lowered)
|
||||||
|
return coreMatch ?? trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAgentDisabled = (agent: string, config: OhMyOpenCodeConfig): boolean => {
|
||||||
|
const lowered = agent.toLowerCase()
|
||||||
|
if (lowered === "sisyphus" && config.sisyphus_agent?.disabled === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (config.disabled_agents ?? []).some(
|
||||||
|
(disabled) => disabled.toLowerCase() === lowered
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFallbackAgent = (config: OhMyOpenCodeConfig): string => {
|
||||||
|
for (const agent of CORE_AGENT_ORDER) {
|
||||||
|
if (!isAgentDisabled(agent, config)) {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveRunAgent = (
|
||||||
|
options: RunOptions,
|
||||||
|
pluginConfig: OhMyOpenCodeConfig,
|
||||||
|
env: EnvVars = process.env
|
||||||
|
): string => {
|
||||||
|
const cliAgent = normalizeAgentName(options.agent)
|
||||||
|
const envAgent = normalizeAgentName(env.OPENCODE_DEFAULT_AGENT)
|
||||||
|
const configAgent = normalizeAgentName(pluginConfig.default_run_agent)
|
||||||
|
const resolved = cliAgent ?? envAgent ?? configAgent ?? DEFAULT_AGENT
|
||||||
|
const normalized = normalizeAgentName(resolved) ?? DEFAULT_AGENT
|
||||||
|
|
||||||
|
if (isAgentDisabled(normalized, pluginConfig)) {
|
||||||
|
const fallback = pickFallbackAgent(pluginConfig)
|
||||||
|
const fallbackDisabled = isAgentDisabled(fallback, pluginConfig)
|
||||||
|
if (fallbackDisabled) {
|
||||||
|
console.log(
|
||||||
|
pc.yellow(
|
||||||
|
`Requested agent "${normalized}" is disabled and no enabled core agent was found. Proceeding with "${fallback}".`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
pc.yellow(
|
||||||
|
`Requested agent "${normalized}" is disabled. Falling back to "${fallback}".`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@ -65,6 +65,8 @@ export interface EventState {
|
|||||||
currentTool: string | null
|
currentTool: string | null
|
||||||
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
||||||
hasReceivedMeaningfulWork: boolean
|
hasReceivedMeaningfulWork: boolean
|
||||||
|
/** Count of assistant messages for the main session */
|
||||||
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEventState(): EventState {
|
export function createEventState(): EventState {
|
||||||
@ -76,6 +78,7 @@ export function createEventState(): EventState {
|
|||||||
lastPartText: "",
|
lastPartText: "",
|
||||||
currentTool: null,
|
currentTool: null,
|
||||||
hasReceivedMeaningfulWork: false,
|
hasReceivedMeaningfulWork: false,
|
||||||
|
messageCount: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +269,7 @@ function handleMessageUpdated(
|
|||||||
if (props?.info?.role !== "assistant") return
|
if (props?.info?.role !== "assistant") return
|
||||||
|
|
||||||
state.hasReceivedMeaningfulWork = true
|
state.hasReceivedMeaningfulWork = true
|
||||||
|
state.messageCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToolExecute(
|
function handleToolExecute(
|
||||||
|
|||||||
@ -1,2 +1,7 @@
|
|||||||
export { run } from "./runner"
|
export { run } from "./runner"
|
||||||
export type { RunOptions, RunContext } from "./types"
|
export { resolveRunAgent } from "./agent-resolver"
|
||||||
|
export { createServerConnection } from "./server-connection"
|
||||||
|
export { resolveSession } from "./session-resolver"
|
||||||
|
export { createJsonOutputManager } from "./json-output"
|
||||||
|
export { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"
|
||||||
|
|||||||
294
src/cli/run/integration.test.ts
Normal file
294
src/cli/run/integration.test.ts
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"
|
||||||
|
import type { RunResult } from "./types"
|
||||||
|
import { createJsonOutputManager } from "./json-output"
|
||||||
|
import { resolveSession } from "./session-resolver"
|
||||||
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
import type { OpencodeClient } from "./types"
|
||||||
|
|
||||||
|
const mockServerClose = mock(() => {})
|
||||||
|
const mockCreateOpencode = mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
client: { session: {} },
|
||||||
|
server: { url: "http://127.0.0.1:9999", close: mockServerClose },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
||||||
|
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
||||||
|
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 9999, wasAutoSelected: false }))
|
||||||
|
|
||||||
|
mock.module("@opencode-ai/sdk", () => ({
|
||||||
|
createOpencode: mockCreateOpencode,
|
||||||
|
createOpencodeClient: mockCreateOpencodeClient,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/port-utils", () => ({
|
||||||
|
isPortAvailable: mockIsPortAvailable,
|
||||||
|
getAvailableServerPort: mockGetAvailableServerPort,
|
||||||
|
DEFAULT_SERVER_PORT: 4096,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createServerConnection } = await import("./server-connection")
|
||||||
|
|
||||||
|
interface MockWriteStream {
|
||||||
|
write: (chunk: string) => boolean
|
||||||
|
writes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWriteStream(): MockWriteStream {
|
||||||
|
const writes: string[] = []
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
write: function (this: MockWriteStream, chunk: string): boolean {
|
||||||
|
this.writes.push(chunk)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockClient = (
|
||||||
|
getResult?: { error?: unknown; data?: { id: string } }
|
||||||
|
): OpencodeClient => ({
|
||||||
|
session: {
|
||||||
|
get: mock((opts: { path: { id: string } }) =>
|
||||||
|
Promise.resolve(getResult ?? { data: { id: opts.path.id } })
|
||||||
|
),
|
||||||
|
create: mock(() => Promise.resolve({ data: { id: "new-session-id" } })),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient)
|
||||||
|
|
||||||
|
describe("integration: --json mode", () => {
|
||||||
|
it("emits valid RunResult JSON to stdout", () => {
|
||||||
|
// given
|
||||||
|
const mockStdout = createMockWriteStream()
|
||||||
|
const mockStderr = createMockWriteStream()
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: "test-session",
|
||||||
|
success: true,
|
||||||
|
durationMs: 1234,
|
||||||
|
messageCount: 42,
|
||||||
|
summary: "Test summary",
|
||||||
|
}
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.emitResult(result)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toHaveLength(1)
|
||||||
|
const emitted = mockStdout.writes[0]!
|
||||||
|
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||||
|
const parsed = JSON.parse(emitted) as RunResult
|
||||||
|
expect(parsed.sessionId).toBe("test-session")
|
||||||
|
expect(parsed.success).toBe(true)
|
||||||
|
expect(parsed.durationMs).toBe(1234)
|
||||||
|
expect(parsed.messageCount).toBe(42)
|
||||||
|
expect(parsed.summary).toBe("Test summary")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects stdout to stderr when active", () => {
|
||||||
|
// given
|
||||||
|
spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
const mockStdout = createMockWriteStream()
|
||||||
|
const mockStderr = createMockWriteStream()
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
manager.redirectToStderr()
|
||||||
|
|
||||||
|
// when
|
||||||
|
mockStdout.write("should go to stderr")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toHaveLength(0)
|
||||||
|
expect(mockStderr.writes).toEqual(["should go to stderr"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("integration: --session-id", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves provided session ID without creating new session", async () => {
|
||||||
|
// given
|
||||||
|
const sessionId = "existing-session-id"
|
||||||
|
const mockClient = createMockClient({ 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 when session does not exist", async () => {
|
||||||
|
// given
|
||||||
|
const sessionId = "non-existent-session-id"
|
||||||
|
const mockClient = createMockClient({ 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("integration: --on-complete", () => {
|
||||||
|
let spawnSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||||
|
exited: Promise.resolve(0),
|
||||||
|
exitCode: 0,
|
||||||
|
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes all 4 env vars as strings to spawned process", async () => {
|
||||||
|
// given
|
||||||
|
spawnSpy.mockClear()
|
||||||
|
|
||||||
|
// when
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: "echo test",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||||
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBe("10")
|
||||||
|
expect(options?.env?.SESSION_ID).toBeTypeOf("string")
|
||||||
|
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||||
|
expect(options?.env?.DURATION_MS).toBeTypeOf("string")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBeTypeOf("string")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("integration: option combinations", () => {
|
||||||
|
let mockStdout: MockWriteStream
|
||||||
|
let mockStderr: MockWriteStream
|
||||||
|
let spawnSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
mockStdout = createMockWriteStream()
|
||||||
|
mockStderr = createMockWriteStream()
|
||||||
|
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||||
|
exited: Promise.resolve(0),
|
||||||
|
exitCode: 0,
|
||||||
|
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
spawnSpy?.mockRestore?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("json output and on-complete hook can both execute", async () => {
|
||||||
|
// given - json manager active + on-complete hook ready
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: "session-123",
|
||||||
|
success: true,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
summary: "Test completed",
|
||||||
|
}
|
||||||
|
const jsonManager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
jsonManager.redirectToStderr()
|
||||||
|
spawnSpy.mockClear()
|
||||||
|
|
||||||
|
// when - both are invoked sequentially (as runner would)
|
||||||
|
jsonManager.emitResult(result)
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: "echo done",
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
exitCode: result.success ? 0 : 1,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
messageCount: result.messageCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then - json emits result AND on-complete hook runs
|
||||||
|
expect(mockStdout.writes).toHaveLength(1)
|
||||||
|
const emitted = mockStdout.writes[0]!
|
||||||
|
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||||
|
expect(args).toEqual(["sh", "-c", "echo done"])
|
||||||
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||||
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBe("10")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("integration: server connection", () => {
|
||||||
|
let consoleSpy: ReturnType<typeof spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = spyOn(console, "log").mockImplementation(() => {})
|
||||||
|
mockCreateOpencode.mockClear()
|
||||||
|
mockCreateOpencodeClient.mockClear()
|
||||||
|
mockServerClose.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("attach mode creates client with no-op cleanup", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const attachUrl = "http://localhost:8080"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("port with available port starts server", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const port = 9999
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ port, signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
expect(mockCreateOpencode).toHaveBeenCalled()
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
170
src/cli/run/json-output.test.ts
Normal file
170
src/cli/run/json-output.test.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "bun:test"
|
||||||
|
import type { RunResult } from "./types"
|
||||||
|
import { createJsonOutputManager } from "./json-output"
|
||||||
|
|
||||||
|
interface MockWriteStream {
|
||||||
|
write: (chunk: string) => boolean
|
||||||
|
writes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWriteStream(): MockWriteStream {
|
||||||
|
const stream: MockWriteStream = {
|
||||||
|
writes: [],
|
||||||
|
write: function (this: MockWriteStream, chunk: string): boolean {
|
||||||
|
this.writes.push(chunk)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createJsonOutputManager", () => {
|
||||||
|
let mockStdout: MockWriteStream
|
||||||
|
let mockStderr: MockWriteStream
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStdout = createMockWriteStream()
|
||||||
|
mockStderr = createMockWriteStream()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("redirectToStderr", () => {
|
||||||
|
it("causes stdout writes to go to stderr", () => {
|
||||||
|
// given
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
manager.redirectToStderr()
|
||||||
|
|
||||||
|
// when
|
||||||
|
mockStdout.write("test message")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toHaveLength(0)
|
||||||
|
expect(mockStderr.writes).toEqual(["test message"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("restore", () => {
|
||||||
|
it("reverses the redirect", () => {
|
||||||
|
// given
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
manager.redirectToStderr()
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.restore()
|
||||||
|
mockStdout.write("restored message")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toEqual(["restored message"])
|
||||||
|
expect(mockStderr.writes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("emitResult", () => {
|
||||||
|
it("writes valid JSON to stdout", () => {
|
||||||
|
// given
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: "test-session",
|
||||||
|
success: true,
|
||||||
|
durationMs: 1234,
|
||||||
|
messageCount: 42,
|
||||||
|
summary: "Test summary",
|
||||||
|
}
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.emitResult(result)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toHaveLength(1)
|
||||||
|
const emitted = mockStdout.writes[0]!
|
||||||
|
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("output matches RunResult schema", () => {
|
||||||
|
// given
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: "test-session",
|
||||||
|
success: true,
|
||||||
|
durationMs: 1234,
|
||||||
|
messageCount: 42,
|
||||||
|
summary: "Test summary",
|
||||||
|
}
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.emitResult(result)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const emitted = mockStdout.writes[0]!
|
||||||
|
const parsed = JSON.parse(emitted) as RunResult
|
||||||
|
expect(parsed).toEqual(result)
|
||||||
|
expect(parsed.sessionId).toBe("test-session")
|
||||||
|
expect(parsed.success).toBe(true)
|
||||||
|
expect(parsed.durationMs).toBe(1234)
|
||||||
|
expect(parsed.messageCount).toBe(42)
|
||||||
|
expect(parsed.summary).toBe("Test summary")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("restores stdout even if redirect was active", () => {
|
||||||
|
// given
|
||||||
|
const result: RunResult = {
|
||||||
|
sessionId: "test-session",
|
||||||
|
success: true,
|
||||||
|
durationMs: 100,
|
||||||
|
messageCount: 1,
|
||||||
|
summary: "Test",
|
||||||
|
}
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
manager.redirectToStderr()
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.emitResult(result)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toHaveLength(1)
|
||||||
|
expect(mockStdout.writes[0]!).toBe(JSON.stringify(result) + "\n")
|
||||||
|
|
||||||
|
mockStdout.write("after emit")
|
||||||
|
expect(mockStdout.writes).toHaveLength(2)
|
||||||
|
expect(mockStderr.writes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiple redirects and restores", () => {
|
||||||
|
it("work correctly", () => {
|
||||||
|
// given
|
||||||
|
const manager = createJsonOutputManager({
|
||||||
|
stdout: mockStdout as unknown as NodeJS.WriteStream,
|
||||||
|
stderr: mockStderr as unknown as NodeJS.WriteStream,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.redirectToStderr()
|
||||||
|
mockStdout.write("first redirect")
|
||||||
|
|
||||||
|
manager.redirectToStderr()
|
||||||
|
mockStdout.write("second redirect")
|
||||||
|
|
||||||
|
manager.restore()
|
||||||
|
mockStdout.write("after restore")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockStdout.writes).toEqual(["after restore"])
|
||||||
|
expect(mockStderr.writes).toEqual(["first redirect", "second redirect"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
52
src/cli/run/json-output.ts
Normal file
52
src/cli/run/json-output.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { RunResult } from "./types"
|
||||||
|
|
||||||
|
export interface JsonOutputManager {
|
||||||
|
redirectToStderr: () => void
|
||||||
|
restore: () => void
|
||||||
|
emitResult: (result: RunResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonOutputManagerOptions {
|
||||||
|
stdout?: NodeJS.WriteStream
|
||||||
|
stderr?: NodeJS.WriteStream
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJsonOutputManager(
|
||||||
|
options: JsonOutputManagerOptions = {}
|
||||||
|
): JsonOutputManager {
|
||||||
|
const stdout = options.stdout ?? process.stdout
|
||||||
|
const stderr = options.stderr ?? process.stderr
|
||||||
|
|
||||||
|
const originalWrite = stdout.write.bind(stdout)
|
||||||
|
|
||||||
|
function redirectToStderr(): void {
|
||||||
|
stdout.write = function (
|
||||||
|
chunk: Uint8Array | string,
|
||||||
|
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||||
|
callback?: (error?: Error | null) => void
|
||||||
|
): boolean {
|
||||||
|
if (typeof encodingOrCallback === "function") {
|
||||||
|
return stderr.write(chunk, encodingOrCallback)
|
||||||
|
}
|
||||||
|
if (encodingOrCallback !== undefined) {
|
||||||
|
return stderr.write(chunk, encodingOrCallback, callback)
|
||||||
|
}
|
||||||
|
return stderr.write(chunk)
|
||||||
|
} as NodeJS.WriteStream["write"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(): void {
|
||||||
|
stdout.write = originalWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitResult(result: RunResult): void {
|
||||||
|
restore()
|
||||||
|
originalWrite(JSON.stringify(result) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectToStderr,
|
||||||
|
restore,
|
||||||
|
emitResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/cli/run/on-complete-hook.test.ts
Normal file
179
src/cli/run/on-complete-hook.test.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||||
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
|
||||||
|
describe("executeOnCompleteHook", () => {
|
||||||
|
function createProc(exitCode: number) {
|
||||||
|
return {
|
||||||
|
exited: Promise.resolve(exitCode),
|
||||||
|
exitCode,
|
||||||
|
} as unknown as ReturnType<typeof Bun.spawn>
|
||||||
|
}
|
||||||
|
|
||||||
|
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("executes command with correct env vars", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: "echo test",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||||
|
|
||||||
|
expect(args).toEqual(["sh", "-c", "echo test"])
|
||||||
|
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||||
|
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||||
|
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBe("10")
|
||||||
|
expect(options?.stdout).toBe("inherit")
|
||||||
|
expect(options?.stderr).toBe("inherit")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("env var values are strings", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: "echo test",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 1,
|
||||||
|
durationMs: 12345,
|
||||||
|
messageCount: 42,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||||
|
|
||||||
|
expect(options?.env?.EXIT_CODE).toBe("1")
|
||||||
|
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||||
|
expect(options?.env?.DURATION_MS).toBe("12345")
|
||||||
|
expect(options?.env?.DURATION_MS).toBeTypeOf("string")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBe("42")
|
||||||
|
expect(options?.env?.MESSAGE_COUNT).toBeTypeOf("string")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("empty command string is no-op", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: "",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnSpy).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("whitespace-only command is no-op", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: " ",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnSpy).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("command failure logs warning but does not throw", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await expect(
|
||||||
|
executeOnCompleteHook({
|
||||||
|
command: "false",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||||
|
const warningCall = consoleErrorSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("Warning: on-complete hook exited with code 1")
|
||||||
|
)
|
||||||
|
expect(warningCall).toBeDefined()
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("spawn error logs warning but does not throw", async () => {
|
||||||
|
// given
|
||||||
|
const spawnError = new Error("Command not found")
|
||||||
|
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
|
||||||
|
throw spawnError
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await expect(
|
||||||
|
executeOnCompleteHook({
|
||||||
|
command: "nonexistent-command",
|
||||||
|
sessionId: "session-123",
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 5000,
|
||||||
|
messageCount: 10,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||||
|
const errorCalls = consoleErrorSpy.mock.calls.filter((call) => {
|
||||||
|
const firstArg = call[0]
|
||||||
|
return typeof firstArg === "string" && (firstArg.includes("Warning") || firstArg.toLowerCase().includes("error"))
|
||||||
|
})
|
||||||
|
expect(errorCalls.length).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
42
src/cli/run/on-complete-hook.ts
Normal file
42
src/cli/run/on-complete-hook.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import pc from "picocolors"
|
||||||
|
|
||||||
|
export async function executeOnCompleteHook(options: {
|
||||||
|
command: string
|
||||||
|
sessionId: string
|
||||||
|
exitCode: number
|
||||||
|
durationMs: number
|
||||||
|
messageCount: number
|
||||||
|
}): Promise<void> {
|
||||||
|
const { command, sessionId, exitCode, durationMs, messageCount } = options
|
||||||
|
|
||||||
|
const trimmedCommand = command.trim()
|
||||||
|
if (!trimmedCommand) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
SESSION_ID: sessionId,
|
||||||
|
EXIT_CODE: String(exitCode),
|
||||||
|
DURATION_MS: String(durationMs),
|
||||||
|
MESSAGE_COUNT: String(messageCount),
|
||||||
|
},
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const hookExitCode = await proc.exited
|
||||||
|
|
||||||
|
if (hookExitCode !== 0) {
|
||||||
|
console.error(
|
||||||
|
pc.yellow(`Warning: on-complete hook exited with code ${hookExitCode}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(pc.yellow(`Warning: Failed to execute on-complete hook: ${error instanceof Error ? error.message : String(error)}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,101 +1,37 @@
|
|||||||
import { createOpencode } from "@opencode-ai/sdk"
|
|
||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { RunOptions, RunContext } from "./types"
|
import type { RunOptions, RunContext } from "./types"
|
||||||
import { checkCompletionConditions } from "./completion"
|
import { checkCompletionConditions } from "./completion"
|
||||||
import { createEventState, processEvents, serializeError } from "./events"
|
import { createEventState, processEvents, serializeError } from "./events"
|
||||||
import type { OhMyOpenCodeConfig } from "../../config"
|
|
||||||
import { loadPluginConfig } from "../../plugin-config"
|
import { loadPluginConfig } from "../../plugin-config"
|
||||||
import { getAvailableServerPort, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
import { createServerConnection } from "./server-connection"
|
||||||
|
import { resolveSession } from "./session-resolver"
|
||||||
|
import { createJsonOutputManager } from "./json-output"
|
||||||
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||||
|
import { resolveRunAgent } from "./agent-resolver"
|
||||||
|
|
||||||
|
export { resolveRunAgent }
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500
|
const POLL_INTERVAL_MS = 500
|
||||||
const DEFAULT_TIMEOUT_MS = 0
|
const DEFAULT_TIMEOUT_MS = 0
|
||||||
const SESSION_CREATE_MAX_RETRIES = 3
|
|
||||||
const SESSION_CREATE_RETRY_DELAY_MS = 1000
|
|
||||||
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
|
|
||||||
const DEFAULT_AGENT = "sisyphus"
|
|
||||||
|
|
||||||
type EnvVars = Record<string, string | undefined>
|
|
||||||
|
|
||||||
const normalizeAgentName = (agent?: string): string | undefined => {
|
|
||||||
if (!agent) return undefined
|
|
||||||
const trimmed = agent.trim()
|
|
||||||
if (!trimmed) return undefined
|
|
||||||
const lowered = trimmed.toLowerCase()
|
|
||||||
const coreMatch = CORE_AGENT_ORDER.find((name) => name.toLowerCase() === lowered)
|
|
||||||
return coreMatch ?? trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAgentDisabled = (agent: string, config: OhMyOpenCodeConfig): boolean => {
|
|
||||||
const lowered = agent.toLowerCase()
|
|
||||||
if (lowered === "sisyphus" && config.sisyphus_agent?.disabled === true) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return (config.disabled_agents ?? []).some(
|
|
||||||
(disabled) => disabled.toLowerCase() === lowered
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickFallbackAgent = (config: OhMyOpenCodeConfig): string => {
|
|
||||||
for (const agent of CORE_AGENT_ORDER) {
|
|
||||||
if (!isAgentDisabled(agent, config)) {
|
|
||||||
return agent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DEFAULT_AGENT
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveRunAgent = (
|
|
||||||
options: RunOptions,
|
|
||||||
pluginConfig: OhMyOpenCodeConfig,
|
|
||||||
env: EnvVars = process.env
|
|
||||||
): string => {
|
|
||||||
const cliAgent = normalizeAgentName(options.agent)
|
|
||||||
const envAgent = normalizeAgentName(env.OPENCODE_DEFAULT_AGENT)
|
|
||||||
const configAgent = normalizeAgentName(pluginConfig.default_run_agent)
|
|
||||||
const resolved = cliAgent ?? envAgent ?? configAgent ?? DEFAULT_AGENT
|
|
||||||
const normalized = normalizeAgentName(resolved) ?? DEFAULT_AGENT
|
|
||||||
|
|
||||||
if (isAgentDisabled(normalized, pluginConfig)) {
|
|
||||||
const fallback = pickFallbackAgent(pluginConfig)
|
|
||||||
const fallbackDisabled = isAgentDisabled(fallback, pluginConfig)
|
|
||||||
if (fallbackDisabled) {
|
|
||||||
console.log(
|
|
||||||
pc.yellow(
|
|
||||||
`Requested agent "${normalized}" is disabled and no enabled core agent was found. Proceeding with "${fallback}".`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
pc.yellow(
|
|
||||||
`Requested agent "${normalized}" is disabled. Falling back to "${fallback}".`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function run(options: RunOptions): Promise<number> {
|
export async function run(options: RunOptions): Promise<number> {
|
||||||
// Set CLI run mode environment variable before any config loading
|
|
||||||
// This signals to config-handler to deny Question tool (no TUI to answer)
|
|
||||||
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
const {
|
const {
|
||||||
message,
|
message,
|
||||||
directory = process.cwd(),
|
directory = process.cwd(),
|
||||||
timeout = DEFAULT_TIMEOUT_MS,
|
timeout = DEFAULT_TIMEOUT_MS,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
const jsonManager = options.json ? createJsonOutputManager() : null
|
||||||
|
if (jsonManager) jsonManager.redirectToStderr()
|
||||||
|
|
||||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||||
|
|
||||||
console.log(pc.cyan("Starting opencode server (auto port selection enabled)..."))
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// timeout=0 means no timeout (run until completion)
|
|
||||||
if (timeout > 0) {
|
if (timeout > 0) {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
||||||
@ -104,29 +40,15 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envPort = process.env.OPENCODE_SERVER_PORT
|
const { client, cleanup: serverCleanup } = await createServerConnection({
|
||||||
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
|
port: options.port,
|
||||||
: undefined
|
attach: options.attach,
|
||||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || "127.0.0.1"
|
|
||||||
const preferredPort = envPort && !isNaN(envPort) ? envPort : DEFAULT_SERVER_PORT
|
|
||||||
|
|
||||||
const { port: serverPort, wasAutoSelected } = await getAvailableServerPort(preferredPort, serverHostname)
|
|
||||||
|
|
||||||
if (wasAutoSelected) {
|
|
||||||
console.log(pc.yellow(`Port ${preferredPort} is busy, using port ${serverPort} instead`))
|
|
||||||
} else {
|
|
||||||
console.log(pc.dim(`Using port ${serverPort}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
const { client, server } = await createOpencode({
|
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
port: serverPort,
|
|
||||||
hostname: serverHostname,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
server.close()
|
serverCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
@ -136,61 +58,14 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Retry session creation with exponential backoff
|
const sessionID = await resolveSession({
|
||||||
// Server might not be fully ready even after "listening" message
|
client,
|
||||||
let sessionID: string | undefined
|
sessionId: options.sessionId,
|
||||||
let lastError: unknown
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= SESSION_CREATE_MAX_RETRIES; attempt++) {
|
|
||||||
const sessionRes = await client.session.create({
|
|
||||||
body: { title: "oh-my-opencode run" },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sessionRes.error) {
|
|
||||||
lastError = sessionRes.error
|
|
||||||
console.error(pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`))
|
|
||||||
console.error(pc.dim(` Error: ${serializeError(sessionRes.error)}`))
|
|
||||||
|
|
||||||
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
|
||||||
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
|
||||||
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID = sessionRes.data?.id
|
|
||||||
if (sessionID) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// No error but also no session ID - unexpected response
|
|
||||||
lastError = new Error(`Unexpected response: ${JSON.stringify(sessionRes, null, 2)}`)
|
|
||||||
console.error(pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned`))
|
|
||||||
|
|
||||||
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
|
||||||
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
|
||||||
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionID) {
|
|
||||||
console.error(pc.red("Failed to create session after all retries"))
|
|
||||||
console.error(pc.dim(`Last error: ${serializeError(lastError)}`))
|
|
||||||
cleanup()
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(pc.dim(`Session: ${sessionID}`))
|
console.log(pc.dim(`Session: ${sessionID}`))
|
||||||
|
|
||||||
const ctx: RunContext = {
|
const ctx: RunContext = { client, sessionID, directory, abortController }
|
||||||
client,
|
|
||||||
sessionID,
|
|
||||||
directory,
|
|
||||||
abortController,
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await client.event.subscribe()
|
const events = await client.event.subscribe()
|
||||||
const eventState = createEventState()
|
const eventState = createEventState()
|
||||||
const eventProcessor = processEvents(ctx, events.stream, eventState)
|
const eventProcessor = processEvents(ctx, events.stream, eventState)
|
||||||
@ -206,47 +81,41 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.log(pc.dim("Waiting for completion...\n"))
|
console.log(pc.dim("Waiting for completion...\n"))
|
||||||
|
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||||
while (!abortController.signal.aborted) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
||||||
|
|
||||||
if (!eventState.mainSessionIdle) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session errored - exit with failure if so
|
|
||||||
if (eventState.mainSessionError) {
|
|
||||||
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
|
|
||||||
console.error(pc.yellow("Check if todos were completed before the error."))
|
|
||||||
cleanup()
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against premature completion: don't check completion until the
|
|
||||||
// session has produced meaningful work (text output, tool call, or tool result).
|
|
||||||
// Without this, a session that goes busy->idle before the LLM responds
|
|
||||||
// would exit immediately because 0 todos + 0 children = "complete".
|
|
||||||
if (!eventState.hasReceivedMeaningfulWork) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldExit = await checkCompletionConditions(ctx)
|
|
||||||
if (shouldExit) {
|
|
||||||
console.log(pc.green("\n\nAll tasks completed."))
|
|
||||||
cleanup()
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await eventProcessor.catch(() => {})
|
await eventProcessor.catch(() => {})
|
||||||
cleanup()
|
cleanup()
|
||||||
return 130
|
|
||||||
|
const durationMs = Date.now() - startTime
|
||||||
|
|
||||||
|
if (options.onComplete) {
|
||||||
|
await executeOnCompleteHook({
|
||||||
|
command: options.onComplete,
|
||||||
|
sessionId: sessionID,
|
||||||
|
exitCode,
|
||||||
|
durationMs,
|
||||||
|
messageCount: eventState.messageCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonManager) {
|
||||||
|
jsonManager.emitResult({
|
||||||
|
sessionId: sessionID,
|
||||||
|
success: exitCode === 0,
|
||||||
|
durationMs,
|
||||||
|
messageCount: eventState.messageCount,
|
||||||
|
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitCode
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup()
|
cleanup()
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
if (jsonManager) jsonManager.restore()
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
return 130
|
return 130
|
||||||
}
|
}
|
||||||
@ -254,3 +123,31 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pollForCompletion(
|
||||||
|
ctx: RunContext,
|
||||||
|
eventState: ReturnType<typeof createEventState>,
|
||||||
|
abortController: AbortController
|
||||||
|
): Promise<number> {
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||||
|
|
||||||
|
if (!eventState.mainSessionIdle) continue
|
||||||
|
|
||||||
|
if (eventState.mainSessionError) {
|
||||||
|
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
|
||||||
|
console.error(pc.yellow("Check if todos were completed before the error."))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventState.hasReceivedMeaningfulWork) continue
|
||||||
|
|
||||||
|
const shouldExit = await checkCompletionConditions(ctx)
|
||||||
|
if (shouldExit) {
|
||||||
|
console.log(pc.green("\n\nAll tasks completed."))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 130
|
||||||
|
}
|
||||||
|
|||||||
152
src/cli/run/server-connection.test.ts
Normal file
152
src/cli/run/server-connection.test.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||||
|
|
||||||
|
const originalConsole = globalThis.console
|
||||||
|
|
||||||
|
const mockServerClose = mock(() => {})
|
||||||
|
const mockCreateOpencode = mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
client: { session: {} },
|
||||||
|
server: { url: "http://127.0.0.1:4096", close: mockServerClose },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
||||||
|
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
||||||
|
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
||||||
|
const mockConsoleLog = mock(() => {})
|
||||||
|
|
||||||
|
mock.module("@opencode-ai/sdk", () => ({
|
||||||
|
createOpencode: mockCreateOpencode,
|
||||||
|
createOpencodeClient: mockCreateOpencodeClient,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/port-utils", () => ({
|
||||||
|
isPortAvailable: mockIsPortAvailable,
|
||||||
|
getAvailableServerPort: mockGetAvailableServerPort,
|
||||||
|
DEFAULT_SERVER_PORT: 4096,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createServerConnection } = await import("./server-connection")
|
||||||
|
|
||||||
|
describe("createServerConnection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreateOpencode.mockClear()
|
||||||
|
mockCreateOpencodeClient.mockClear()
|
||||||
|
mockIsPortAvailable.mockClear()
|
||||||
|
mockGetAvailableServerPort.mockClear()
|
||||||
|
mockServerClose.mockClear()
|
||||||
|
mockConsoleLog.mockClear()
|
||||||
|
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.console = originalConsole
|
||||||
|
})
|
||||||
|
|
||||||
|
it("attach mode returns client with no-op cleanup", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const attachUrl = "http://localhost:8080"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explicit port starts server when port is available", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const port = 8080
|
||||||
|
mockIsPortAvailable.mockResolvedValueOnce(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ port, signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
||||||
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" })
|
||||||
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explicit port attaches when port is occupied", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const port = 8080
|
||||||
|
mockIsPortAvailable.mockResolvedValueOnce(false)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ port, signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
||||||
|
expect(mockCreateOpencode).not.toHaveBeenCalled()
|
||||||
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("auto mode uses getAvailableServerPort", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
mockGetAvailableServerPort.mockResolvedValueOnce({ port: 4100, wasAutoSelected: true })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ signal })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1")
|
||||||
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" })
|
||||||
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
||||||
|
expect(result.client).toBeDefined()
|
||||||
|
expect(result.cleanup).toBeDefined()
|
||||||
|
result.cleanup()
|
||||||
|
expect(mockServerClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("invalid port throws error", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
|
// when & then
|
||||||
|
await expect(createServerConnection({ port: 0, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
||||||
|
await expect(createServerConnection({ port: -1, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
||||||
|
await expect(createServerConnection({ port: 99999, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cleanup calls server.close for owned server", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
mockIsPortAvailable.mockResolvedValueOnce(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ port: 8080, signal })
|
||||||
|
result.cleanup()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockServerClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cleanup is no-op for attached server", async () => {
|
||||||
|
// given
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const attachUrl = "http://localhost:8080"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||||
|
result.cleanup()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockServerClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/cli/run/server-connection.ts
Normal file
47
src/cli/run/server-connection.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import pc from "picocolors"
|
||||||
|
import type { ServerConnection } from "./types"
|
||||||
|
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
||||||
|
|
||||||
|
export async function createServerConnection(options: {
|
||||||
|
port?: number
|
||||||
|
attach?: string
|
||||||
|
signal: AbortSignal
|
||||||
|
}): Promise<ServerConnection> {
|
||||||
|
const { port, attach, signal } = options
|
||||||
|
|
||||||
|
if (attach !== undefined) {
|
||||||
|
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
|
||||||
|
const client = createOpencodeClient({ baseUrl: attach })
|
||||||
|
return { client, cleanup: () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port !== undefined) {
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
throw new Error("Port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = await isPortAvailable(port, "127.0.0.1")
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
|
||||||
|
const { client, server } = await createOpencode({ signal, port, hostname: "127.0.0.1" })
|
||||||
|
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
|
||||||
|
return { client, cleanup: () => server.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
|
||||||
|
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||||
|
return { client, cleanup: () => {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { port: selectedPort, wasAutoSelected } = await getAvailableServerPort(DEFAULT_SERVER_PORT, "127.0.0.1")
|
||||||
|
if (wasAutoSelected) {
|
||||||
|
console.log(pc.dim("Auto-selected port"), pc.cyan(selectedPort.toString()))
|
||||||
|
} else {
|
||||||
|
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
|
||||||
|
}
|
||||||
|
const { client, server } = await createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" })
|
||||||
|
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
|
||||||
|
return { client, cleanup: () => server.close() }
|
||||||
|
}
|
||||||
140
src/cli/run/session-resolver.test.ts
Normal file
140
src/cli/run/session-resolver.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
64
src/cli/run/session-resolver.ts
Normal file
64
src/cli/run/session-resolver.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import pc from "picocolors"
|
||||||
|
import type { OpencodeClient } from "./types"
|
||||||
|
import { serializeError } from "./events"
|
||||||
|
|
||||||
|
const SESSION_CREATE_MAX_RETRIES = 3
|
||||||
|
const SESSION_CREATE_RETRY_DELAY_MS = 1000
|
||||||
|
|
||||||
|
export async function resolveSession(options: {
|
||||||
|
client: OpencodeClient
|
||||||
|
sessionId?: string
|
||||||
|
}): Promise<string> {
|
||||||
|
const { client, sessionId } = options
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const res = await client.session.get({ path: { id: sessionId } })
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
throw new Error(`Session not found: ${sessionId}`)
|
||||||
|
}
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 1; attempt <= SESSION_CREATE_MAX_RETRIES; attempt++) {
|
||||||
|
const res = await client.session.create({
|
||||||
|
body: { title: "oh-my-opencode run" },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.error) {
|
||||||
|
lastError = res.error
|
||||||
|
console.error(
|
||||||
|
pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`)
|
||||||
|
)
|
||||||
|
console.error(pc.dim(` Error: ${serializeError(res.error)}`))
|
||||||
|
|
||||||
|
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
||||||
|
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
||||||
|
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data?.id) {
|
||||||
|
return res.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new Error(
|
||||||
|
`Unexpected response: ${JSON.stringify(res, null, 2)}`
|
||||||
|
)
|
||||||
|
console.error(
|
||||||
|
pc.yellow(
|
||||||
|
`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (attempt < SESSION_CREATE_MAX_RETRIES) {
|
||||||
|
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
|
||||||
|
console.log(pc.dim(` Retrying in ${delay}ms...`))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to create session after all retries")
|
||||||
|
}
|
||||||
@ -1,10 +1,29 @@
|
|||||||
import type { OpencodeClient } from "@opencode-ai/sdk"
|
import type { OpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
export type { OpencodeClient }
|
||||||
|
|
||||||
export interface RunOptions {
|
export interface RunOptions {
|
||||||
message: string
|
message: string
|
||||||
agent?: string
|
agent?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
timeout?: number
|
timeout?: number
|
||||||
|
port?: number
|
||||||
|
attach?: string
|
||||||
|
onComplete?: string
|
||||||
|
json?: boolean
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerConnection {
|
||||||
|
client: OpencodeClient
|
||||||
|
cleanup: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunResult {
|
||||||
|
sessionId: string
|
||||||
|
success: boolean
|
||||||
|
durationMs: number
|
||||||
|
messageCount: number
|
||||||
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunContext {
|
export interface RunContext {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user