mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* ci: share test transforms and remove six redundant checks * test: remove obsolete project copies and two build string checks * test: use the injected clock for exact launch readiness budgets * fix: retain IME confirmation and retire disconnected Windows PTYs * ci: include relay installer in existing Windows boundaries * perf: import the canonical SSH manager for filesystem operations * test: remove 42 source-spelling checks * test: advance deadlines after real entry and await actual PTY exit * Preserve native Windows exit acknowledgments and skip daemon-only smoke triggers
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const { getPathMock } = vi.hoisted(() => ({
|
|
getPathMock: vi.fn<(name: string) => string>()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
|
|
|
|
import { _internals } from './hook-service'
|
|
|
|
// Why: live probe of opencode v2.0.12 — session.get resolves the session record
|
|
// itself (no { data } envelope), rejects the legacy { path: { id } } argument,
|
|
// and session.list does not exist. Anything less faithful hides the defect.
|
|
type LiveSession = { id: string; parentID?: string }
|
|
|
|
const ROOT = 'ses_root'
|
|
const CHILD = 'ses_child'
|
|
const LIVE_SESSIONS: Record<string, LiveSession> = {
|
|
[ROOT]: { id: ROOT },
|
|
[CHILD]: { id: CHILD, parentID: ROOT }
|
|
}
|
|
|
|
type Post = { hook_event_name: string; sessionID?: string }
|
|
|
|
const ENV_KEYS = [
|
|
'ORCA_PANE_KEY',
|
|
'ORCA_OPENCODE_AGENT',
|
|
'ORCA_AGENT_HOOK_ENDPOINT',
|
|
'ORCA_AGENT_HOOK_PORT',
|
|
'ORCA_AGENT_HOOK_TOKEN'
|
|
] as const
|
|
|
|
describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage', (agent) => {
|
|
let tempDir: string
|
|
let savedFetch: typeof globalThis.fetch
|
|
let savedEnv: Record<string, string | undefined>
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode2-lineage-'))
|
|
savedFetch = globalThis.fetch
|
|
savedEnv = {}
|
|
for (const key of ENV_KEYS) {
|
|
savedEnv[key] = process.env[key]
|
|
}
|
|
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
|
process.env.ORCA_OPENCODE_AGENT = agent
|
|
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
|
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
|
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = savedFetch
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key]
|
|
} else {
|
|
process.env[key] = savedEnv[key]
|
|
}
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function loadPlugin(): Promise<{
|
|
default?: {
|
|
server?: (ctx: unknown) => Promise<{ event: (input: { event: unknown }) => Promise<void> }>
|
|
setup?: (ctx: unknown) => Promise<() => Promise<void>>
|
|
}
|
|
}> {
|
|
const source =
|
|
agent === 'opencode2'
|
|
? _internals.getOpenCode2PluginSource()
|
|
: _internals.getOpenCodePluginSource()
|
|
const pluginPath = join(tempDir, `plugin-${Math.random().toString(36).slice(2)}.mjs`)
|
|
writeFileSync(pluginPath, source)
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the local fixture module is written from the generated plugin source above.
|
|
return (await import(pathToFileURL(pluginPath).href)) as Awaited<ReturnType<typeof loadPlugin>>
|
|
}
|
|
|
|
async function runSetupBridge(
|
|
events: { type: string; data: Record<string, unknown> }[]
|
|
): Promise<{
|
|
posts: Post[]
|
|
lookups: string[]
|
|
cleanup?: () => Promise<void>
|
|
}> {
|
|
const posts: Post[] = []
|
|
const lookups: string[] = []
|
|
globalThis.fetch = vi.fn(async (_input, init) => {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the plugin always posts a JSON body carrying `payload`.
|
|
const body = JSON.parse(String(init?.body)) as { payload: Post }
|
|
posts.push(body.payload)
|
|
return new Response('{}', { status: 200 })
|
|
})
|
|
const module = await loadPlugin()
|
|
const cleanup = await module.default?.setup?.({
|
|
session: {
|
|
// Why: one declared parameter, exactly like the shipped SDK method.
|
|
get: async ({ sessionID }: { sessionID?: string }) => {
|
|
if (!sessionID) {
|
|
throw new Error('Missing key at ["sessionID"]')
|
|
}
|
|
lookups.push(sessionID)
|
|
const session = LIVE_SESSIONS[sessionID]
|
|
if (!session) {
|
|
throw new Error('unknown session')
|
|
}
|
|
return session
|
|
},
|
|
hook: async () => ({ dispose: vi.fn() })
|
|
},
|
|
event: {
|
|
subscribe: async function* () {
|
|
for (const event of events) {
|
|
yield event
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return { posts, lookups, cleanup }
|
|
}
|
|
|
|
function created(sessionID: string): { type: string; data: Record<string, unknown> } {
|
|
const { parentID } = LIVE_SESSIONS[sessionID]
|
|
return { type: 'session.created', data: { sessionID, ...(parentID ? { parentID } : {}) } }
|
|
}
|
|
|
|
function questionForm(id: string, sessionID: string): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
sessionID,
|
|
title: 'Questions',
|
|
metadata: { kind: 'question', tool: { messageID: 'msg-0', id: 'tool-0' } },
|
|
fields: [{ key: 'q0', title: 'Proceed?', type: 'string', options: [] }]
|
|
}
|
|
}
|
|
|
|
it('resolves child lineage from the flat OpenCode 2 session shape', async () => {
|
|
const { posts, lookups, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
created(CHILD),
|
|
{ type: 'session.execution.started', data: { sessionID: ROOT } },
|
|
{ type: 'session.execution.started', data: { sessionID: CHILD } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.filter((post) => post.hook_event_name === 'SessionBusy').length).toBeGreaterThan(
|
|
0
|
|
)
|
|
})
|
|
// The child rolls up under its root; it never publishes as a root itself.
|
|
expect(posts.map((post) => post.sessionID)).not.toContain(CHILD)
|
|
expect(lookups).toContain(CHILD)
|
|
await cleanup?.()
|
|
})
|
|
|
|
it('attributes a child question to the root instead of minting a child blocker', async () => {
|
|
const { posts, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
created(CHILD),
|
|
{ type: 'session.execution.started', data: { sessionID: ROOT } },
|
|
{ type: 'form.created', data: { form: questionForm('form-child', CHILD) } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion')
|
|
})
|
|
expect(posts.filter((post) => post.hook_event_name === 'AskUserQuestion')).toEqual([
|
|
expect.objectContaining({ sessionID: ROOT })
|
|
])
|
|
await cleanup?.()
|
|
})
|
|
|
|
it('retires a child raised blocker when that child goes idle', async () => {
|
|
const { posts, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
created(CHILD),
|
|
{ type: 'session.execution.started', data: { sessionID: ROOT } },
|
|
{ type: 'form.created', data: { form: questionForm('form-child', CHILD) } },
|
|
{ type: 'session.execution.succeeded', data: { sessionID: CHILD } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion')
|
|
})
|
|
// The root turn is still running, so the pane goes back to busy rather than staying blocked.
|
|
await vi.waitFor(() => {
|
|
expect(posts.at(-1)?.hook_event_name).toBe('SessionBusy')
|
|
})
|
|
expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT }))
|
|
await cleanup?.()
|
|
})
|
|
|
|
// Why this case: it is the shape reported in #22371 — a subagent asks, the lead
|
|
// agent truthfully reports no pending question, and the pane stays "needs input"
|
|
// with nothing able to clear it. With lineage unresolved the blocker keys to the
|
|
// child's own session id, so the root's own idle never matches it.
|
|
it('retires a child raised blocker when the root turn goes idle', async () => {
|
|
const { posts, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
created(CHILD),
|
|
{ type: 'session.execution.started', data: { sessionID: ROOT } },
|
|
{ type: 'form.created', data: { form: questionForm('form-child', CHILD) } },
|
|
{ type: 'session.execution.succeeded', data: { sessionID: ROOT } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion')
|
|
})
|
|
await vi.waitFor(() => {
|
|
expect(posts.at(-1)?.hook_event_name).toBe('SessionIdle')
|
|
})
|
|
expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT }))
|
|
await cleanup?.()
|
|
})
|
|
|
|
it('still blocks the pane on the root session own question', async () => {
|
|
const { posts, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
{ type: 'session.execution.started', data: { sessionID: ROOT } },
|
|
{ type: 'form.created', data: { form: questionForm('form-root', ROOT) } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion')
|
|
})
|
|
expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT }))
|
|
await cleanup?.()
|
|
})
|
|
|
|
it('caches resolved ancestry instead of re-probing per event', async () => {
|
|
const { posts, lookups, cleanup } = await runSetupBridge([
|
|
created(ROOT),
|
|
created(CHILD),
|
|
{ type: 'session.execution.started', data: { sessionID: CHILD } },
|
|
{ type: 'session.execution.succeeded', data: { sessionID: CHILD } },
|
|
{ type: 'session.execution.started', data: { sessionID: CHILD } },
|
|
{ type: 'session.execution.succeeded', data: { sessionID: CHILD } }
|
|
])
|
|
await vi.waitFor(() => {
|
|
expect(posts.length).toBeGreaterThan(0)
|
|
})
|
|
// The root arrived via session.created, so only the child needs one lookup.
|
|
expect(lookups).toEqual([CHILD])
|
|
await cleanup?.()
|
|
})
|
|
})
|