- Add directory parameter to session API calls (session.get, session.todo,
session.status, session.children)
- Improve agent resolver with display name support via agent-display-names
- Add tool execution visibility in event handlers with running/completed
status output
- Enhance poll-for-completion with main session status checking and
stabilization period handling
- Add normalizeSDKResponse import for consistent response handling
- Update types with Todo, ChildSession, and toast-related interfaces
🤖 Generated with OhMyOpenCode assistance
308 lines
9.5 KiB
TypeScript
308 lines
9.5 KiB
TypeScript
import { describe, it, expect, mock, spyOn, beforeEach, afterEach, afterAll } 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"
|
|
import * as originalSdk from "@opencode-ai/sdk"
|
|
import * as originalPortUtils from "../../shared/port-utils"
|
|
|
|
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,
|
|
}))
|
|
|
|
afterAll(() => {
|
|
mock.module("@opencode-ai/sdk", () => originalSdk)
|
|
mock.module("../../shared/port-utils", () => originalPortUtils)
|
|
})
|
|
|
|
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, directory: "/test" })
|
|
|
|
// then
|
|
expect(result).toBe(sessionId)
|
|
expect(mockClient.session.get).toHaveBeenCalledWith({
|
|
path: { id: sessionId },
|
|
query: { directory: "/test" },
|
|
})
|
|
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, directory: "/test" })
|
|
|
|
// then
|
|
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
|
expect(mockClient.session.get).toHaveBeenCalledWith({
|
|
path: { id: sessionId },
|
|
query: { directory: "/test" },
|
|
})
|
|
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()
|
|
})
|
|
})
|