Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions packages/codesurf-daemon/bin/chat-jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ import { resolveAuthoritativeAgentMode } from './agent-mode-resolver.mjs'
import {
OMNIGENT_DEFAULT_BASE_URL,
OMNIGENT_DEFAULT_CLI,
buildOmnigentSessionBody,
chooseOmnigentHost,
decodeOmnigentModelId,
extractOmnigentSessionId,
mapOmnigentStreamEvent,
normalizeOmnigentServerRoot,
omnigentAuthHeaders,
omnigentEndpointUrl,
parseOmnigentHosts,
parseOmnigentServerUrl,
parseOmnigentSseChunk,
parseOmnigentStatusJson,
Expand Down Expand Up @@ -678,6 +681,25 @@ async function resolveOmnigentAgentId(modelId, settings, baseUrl, apiKey, signal
return first.id.trim()
}

// Resolve the runner host_id to bind a new Omnigent session to. Without it the
// backend never launches a runner and every turn fails with runner_unavailable
// ("No runner bound for session"). settings.hostId wins (operator pin); else GET
// /v1/hosts and auto-pick the first online host (first host if none report
// status), mirroring the CLI provider. FAIL CLOSED on zero hosts: throw a clear
// error rather than create a session that is guaranteed to fail at turn time.
async function resolveOmnigentHostId(settings, baseUrl, apiKey, signal) {
const configured = String(settings?.hostId ?? '').trim()
if (configured) return configured
const payload = await omnigentFetchJson(baseUrl, '/v1/hosts', apiKey, signal ? { signal } : {})
const hosts = parseOmnigentHosts(payload)
if (hosts.length === 0) {
throw new Error('Omnigent returned no runner hosts from /v1/hosts; no runner host is registered. Start a runner host (or set settings.omnigent.hostId) before creating a session.')
}
const chosen = chooseOmnigentHost(hosts)
if (!chosen?.id) throw new Error('Omnigent /v1/hosts returned hosts without a usable id.')
return chosen.id
}

function omnigentTitleFromPrompt(text) {
const oneLine = String(text ?? '').replace(/\s+/g, ' ').trim()
if (!oneLine) return 'CodeSurf session'
Expand Down Expand Up @@ -2195,11 +2217,17 @@ export function createChatJobManager({ homeDir, checkpointStore = null, claudeQu
let sessionId = typeof request.sessionId === 'string' && request.sessionId.trim() ? request.sessionId.trim() : null
if (!sessionId) {
const agentId = await resolveOmnigentAgentId(request.model, settings, baseUrl, apiKey, abortController.signal)
const body = {
agent_id: agentId,
// Bind the session to a runner host. Required: a session created without
// host_id never gets a runner bound, so its turns fail with
// runner_unavailable. Resolved before create so a missing host fails the
// job cleanly here instead of mid-turn.
const hostId = await resolveOmnigentHostId(settings, baseUrl, apiKey, abortController.signal)
const body = buildOmnigentSessionBody({
agentId,
hostId,
title: omnigentTitleFromPrompt(lastUserMsg.content ?? ''),
...(workspaceDir ? { workspace: workspaceDir } : {}),
}
workspace: workspaceDir,
})
const created = await omnigentFetchJson(baseUrl, '/v1/sessions', apiKey, {
method: 'POST',
body: JSON.stringify(body),
Expand Down
53 changes: 53 additions & 0 deletions packages/codesurf-daemon/bin/omnigent-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,59 @@ export function extractOmnigentSessionId(payload) {
return typeof nestedId === 'string' && nestedId.trim() ? nestedId.trim() : null
}

// Normalize the /v1/hosts response into `{ id, name, status }` rows. Mirrors the
// CLI provider's parseOmnigentHosts: accept a bare array, `{ hosts: [...] }`, or
// `{ data: [...] }`, and prefer `host_id` over `id` for the runner id (the
// backend keys runner registration on host_id). Rows without an id are dropped.
export function parseOmnigentHosts(payload) {
const record = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}
const rows = Array.isArray(payload)
? payload
: Array.isArray(record.hosts)
? record.hosts
: Array.isArray(record.data)
? record.data
: []
return rows
.map(item => {
const row = item && typeof item === 'object' ? item : null
const id = typeof row?.host_id === 'string' && row.host_id.trim()
? row.host_id.trim()
: typeof row?.id === 'string' && row.id.trim()
? row.id.trim()
: ''
if (!id) return null
const name = typeof row?.name === 'string' && row.name.trim() ? row.name.trim() : undefined
const status = typeof row?.status === 'string' && row.status.trim() ? row.status.trim() : undefined
return { id, name, status }
})
.filter(Boolean)
}

// Pick the runner host to bind a new session to: the first host reporting status
// `online`, else the first host (covers backends that omit status). Status is
// compared case-insensitively — backends/CLIs report ONLINE/Online/online — so a
// non-lowercase online host isn't missed in favor of a stale first host. Mirrors
// the CLI provider's chooseOmnigentHost. Returns the chosen row or null when empty.
export function chooseOmnigentHost(hosts) {
const rows = Array.isArray(hosts) ? hosts : []
return rows.find(host => String(host?.status ?? '').toLowerCase() === 'online') ?? rows[0] ?? null
}

// Build the POST /v1/sessions request body. Pure + exported (mirrors
// buildCodexExecArgs in chat-jobs.mjs) so the daemon test can assert host_id is
// included alongside agent_id without importing the Claude SDK that chat-jobs.mjs
// pulls in. host_id is the load-bearing field: without it the Omnigent backend
// never binds a runner and every turn fails with runner_unavailable.
export function buildOmnigentSessionBody({ agentId, hostId, title, workspace } = {}) {
return {
agent_id: agentId,
...(hostId ? { host_id: hostId } : {}),
title,
...(workspace ? { workspace } : {}),
}
}

export function parseOmnigentServerUrl(output) {
const match = String(output ?? '').match(/https?:\/\/[^\s)]+/i)
return match ? normalizeOmnigentServerRoot(match[0]) : null
Expand Down
8 changes: 7 additions & 1 deletion packages/codesurf-daemon/bin/omnigent-settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// "baseUrl": "http://127.0.0.1:6767",
// "apiKey": "",
// "agentId": "",
// "hostId": "",
// "autoStart": true
// } } }
//
Expand Down Expand Up @@ -46,12 +47,17 @@ export function resolveOmnigentSettings({ settings, env = process.env } = {}) {
const agentId = envString(env?.CODESURF_OMNIGENT_AGENT_ID)
?? (typeof cfg.agentId === 'string' ? cfg.agentId.trim() : '')

// Optional runner host pin. Blank (default) means auto-pick the first online
// host from /v1/hosts at session-create time (see resolveOmnigentHostId).
const hostId = envString(env?.CODESURF_OMNIGENT_HOST_ID)
?? (typeof cfg.hostId === 'string' ? cfg.hostId.trim() : '')

const autoStart = envFlag(env?.CODESURF_OMNIGENT_AUTO_START, cfg.autoStart !== false)

const enabled = envFlag(
env?.CODESURF_OMNIGENT_ENABLED ?? env?.CODESURF_OMNIGENT,
cfg.enabled !== false,
)

return { enabled, baseUrl, apiKey, agentId, autoStart }
return { enabled, baseUrl, apiKey, agentId, hostId, autoStart }
}
46 changes: 45 additions & 1 deletion packages/codesurf-daemon/test/omnigent.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { test } from 'node:test'
import { resolveOmnigentSettings } from '../bin/omnigent-settings.mjs'
import {
OMNIGENT_DEFAULT_BASE_URL,
buildOmnigentSessionBody,
chooseOmnigentHost,
decodeOmnigentModelId,
extractOmnigentSessionId,
mapOmnigentStreamEvent,
normalizeOmnigentServerRoot,
omnigentAuthHeaders,
omnigentEndpointUrl,
parseOmnigentHosts,
parseOmnigentSseChunk,
} from '../bin/omnigent-provider.mjs'

Expand All @@ -20,18 +23,20 @@ test('resolveOmnigentSettings defaults are sensible and provider works out of th
baseUrl: OMNIGENT_DEFAULT_BASE_URL,
apiKey: '',
agentId: '',
hostId: '',
autoStart: true,
})
})

test('resolveOmnigentSettings reads settings.omnigent and honors env overrides', () => {
const settings = {
omnigent: { enabled: true, baseUrl: 'http://host:9000/v1', apiKey: 'cfg', agentId: 'agent-cfg', autoStart: false },
omnigent: { enabled: true, baseUrl: 'http://host:9000/v1', apiKey: 'cfg', agentId: 'agent-cfg', hostId: 'host-cfg', autoStart: false },
}
const fromCfg = resolveOmnigentSettings({ settings, env: {} })
assert.equal(fromCfg.baseUrl, 'http://host:9000/v1')
assert.equal(fromCfg.apiKey, 'cfg')
assert.equal(fromCfg.agentId, 'agent-cfg')
assert.equal(fromCfg.hostId, 'host-cfg')
assert.equal(fromCfg.autoStart, false)

const overridden = resolveOmnigentSettings({
Expand All @@ -40,17 +45,56 @@ test('resolveOmnigentSettings reads settings.omnigent and honors env overrides',
CODESURF_OMNIGENT_BASE_URL: 'http://override:1234',
CODESURF_OMNIGENT_API_KEY: 'env-key',
CODESURF_OMNIGENT_AGENT_ID: 'env-agent',
CODESURF_OMNIGENT_HOST_ID: 'env-host',
CODESURF_OMNIGENT_AUTO_START: 'true',
CODESURF_OMNIGENT_ENABLED: 'false',
},
})
assert.equal(overridden.baseUrl, 'http://override:1234')
assert.equal(overridden.apiKey, 'env-key')
assert.equal(overridden.agentId, 'env-agent')
assert.equal(overridden.hostId, 'env-host')
assert.equal(overridden.autoStart, true)
assert.equal(overridden.enabled, false)
})

test('parseOmnigentHosts normalizes array / {hosts} / {data} shapes and prefers host_id', () => {
// host_id wins over id; rows without an id are dropped.
assert.deepEqual(parseOmnigentHosts([{ host_id: 'h1', name: 'A', status: 'online' }, { id: 'h2' }, { name: 'no-id' }]), [
{ id: 'h1', name: 'A', status: 'online' },
{ id: 'h2', name: undefined, status: undefined },
])
assert.deepEqual(parseOmnigentHosts({ hosts: [{ host_id: 'hh' }] }), [{ id: 'hh', name: undefined, status: undefined }])
assert.deepEqual(parseOmnigentHosts({ data: [{ id: 'hd', status: 'offline' }] }), [{ id: 'hd', name: undefined, status: 'offline' }])
assert.deepEqual(parseOmnigentHosts(null), [])
})

test('chooseOmnigentHost auto-picks the first online host, else the first host', () => {
// Auto-pick: the online host wins even when a non-online host comes first.
assert.equal(chooseOmnigentHost([{ id: 'a', status: 'offline' }, { id: 'b', status: 'online' }])?.id, 'b')
// Status match is case-insensitive: an uppercase ONLINE host is picked over a
// non-online first host (backends/CLIs report ONLINE/Online/online).
assert.equal(chooseOmnigentHost([{ id: 'a', status: 'OFFLINE' }, { id: 'b', status: 'ONLINE' }])?.id, 'b')
// Fall back to the first host when none report status.
assert.equal(chooseOmnigentHost([{ id: 'a' }, { id: 'b' }])?.id, 'a')
assert.equal(chooseOmnigentHost([]), null)
})

test('buildOmnigentSessionBody includes host_id alongside agent_id (the runner-binding fix)', () => {
const onlineHost = chooseOmnigentHost(parseOmnigentHosts({ hosts: [{ host_id: 'runner-1', status: 'online' }] }))
const body = buildOmnigentSessionBody({
agentId: 'agent-x',
hostId: onlineHost.id,
title: 'hello',
workspace: '/tmp/ws',
})
assert.deepEqual(body, { agent_id: 'agent-x', host_id: 'runner-1', title: 'hello', workspace: '/tmp/ws' })
// host_id is the load-bearing field: it must be present so the backend binds a runner.
assert.equal(body.host_id, 'runner-1')
// workspace is omitted when not provided; a blank hostId drops host_id entirely.
assert.deepEqual(buildOmnigentSessionBody({ agentId: 'a', title: 't' }), { agent_id: 'a', title: 't' })
})

test('normalizeOmnigentServerRoot strips trailing slash and /v1', () => {
assert.equal(normalizeOmnigentServerRoot('http://x:6767/'), 'http://x:6767')
assert.equal(normalizeOmnigentServerRoot('http://x:6767/v1'), 'http://x:6767')
Expand Down
Loading