mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
796 lines
26 KiB
TypeScript
796 lines
26 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
assertKnownFlags,
|
|
attachArgs,
|
|
buildCreateSessionBody,
|
|
codeFromBranch,
|
|
DEFAULT_AGENT_SERVER_HOST,
|
|
DEFAULT_AGENT_SERVER_PORT,
|
|
deriveSessionStatus,
|
|
driverArgs,
|
|
driverLogPath,
|
|
formatSessionList,
|
|
isLoopbackHost,
|
|
isStaleLock,
|
|
MODEL_VARIANT,
|
|
nearestAcceptedFlag,
|
|
parseModelRef,
|
|
parseServerState,
|
|
parseSessionState,
|
|
purposeInvocation,
|
|
resolveServerConfig,
|
|
resolveSessionRef,
|
|
resolveStartDecision,
|
|
serializeServerState,
|
|
serializeSessionState,
|
|
serverArgs,
|
|
serverStatePath,
|
|
SESSION_COMMAND_BY_PURPOSE,
|
|
SESSION_FLAG_ALLOWLIST,
|
|
sessionDirFromEnv,
|
|
sessionListPayload,
|
|
sessionSlug,
|
|
sessionStatePath,
|
|
startLockPath,
|
|
STATE_VERSION,
|
|
validateServerBind,
|
|
validateSessionFlags,
|
|
} from '../../scripts/lib/agent-session.mjs'
|
|
|
|
const state = (over: Record<string, unknown> = {}) => ({
|
|
version: STATE_VERSION,
|
|
code: 'OPS110',
|
|
issue: 1019,
|
|
purpose: 'next',
|
|
branch: 'OPS110-acompanhar-runs',
|
|
dir: '/home/fsolla/.cursor/worktrees/teqo/OPS110-acompanhar-runs',
|
|
sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3',
|
|
url: `http://${DEFAULT_AGENT_SERVER_HOST}:${DEFAULT_AGENT_SERVER_PORT}`,
|
|
model: 'deepseek/deepseek-flash',
|
|
driverPid: 4242,
|
|
logPath: '/home/fsolla/.local/state/teqo/agent-sessions/ops110-acompanhar-runs.log',
|
|
startedAt: '2026-09-15T10:00:00.000Z',
|
|
stoppedAt: null,
|
|
...over,
|
|
})
|
|
|
|
describe('resolveServerConfig (OPS110 — loopback por padrão, env/flag overridáveis)', () => {
|
|
it('defaults to loopback:4199', () => {
|
|
expect(resolveServerConfig({ env: {} })).toEqual({
|
|
hostname: '127.0.0.1',
|
|
port: 4199,
|
|
url: 'http://127.0.0.1:4199',
|
|
})
|
|
expect(DEFAULT_AGENT_SERVER_HOST).toBe('127.0.0.1')
|
|
expect(DEFAULT_AGENT_SERVER_PORT).toBe(4199)
|
|
})
|
|
|
|
it('reads TEQO_AGENT_SERVER_HOST/PORT from the env', () => {
|
|
expect(
|
|
resolveServerConfig({
|
|
env: { TEQO_AGENT_SERVER_HOST: '100.119.220.31', TEQO_AGENT_SERVER_PORT: '4300' },
|
|
}),
|
|
).toEqual({ hostname: '100.119.220.31', port: 4300, url: 'http://100.119.220.31:4300' })
|
|
})
|
|
|
|
it('explicit flags win over the env (serve --hostname/--port)', () => {
|
|
expect(
|
|
resolveServerConfig({
|
|
hostname: '127.0.0.1',
|
|
port: 5000,
|
|
env: { TEQO_AGENT_SERVER_PORT: '4300' },
|
|
}),
|
|
).toEqual({ hostname: '127.0.0.1', port: 5000, url: 'http://127.0.0.1:5000' })
|
|
})
|
|
|
|
it('rejects a non-numeric/out-of-range port — fail high, never guess', () => {
|
|
expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: 'abc' } })).toThrow(/porta/)
|
|
expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: '70000' } })).toThrow(/porta/)
|
|
})
|
|
})
|
|
|
|
describe('validateServerBind + isLoopbackHost (aceite: nada além do loopback sem credencial)', () => {
|
|
it('recognizes loopback spellings only', () => {
|
|
for (const host of ['127.0.0.1', '127.0.0.5', 'localhost', '::1', '[::1]', ' LOCALHOST ']) {
|
|
expect(isLoopbackHost(host)).toBe(true)
|
|
}
|
|
for (const host of [
|
|
'0.0.0.0',
|
|
'100.119.220.31',
|
|
'192.168.1.10',
|
|
'jorgesolla1313.com.br',
|
|
'127.0.0.1.evil',
|
|
'127.0.0.1.example.com',
|
|
]) {
|
|
expect(isLoopbackHost(host)).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('loopback never requires a password', () => {
|
|
expect(validateServerBind({ hostname: '127.0.0.1', password: '' })).toBe(true)
|
|
})
|
|
|
|
it('non-loopback without OPENCODE_SERVER_PASSWORD fails closed', () => {
|
|
expect(() => validateServerBind({ hostname: '100.119.220.31', password: '' })).toThrow(
|
|
/OPENCODE_SERVER_PASSWORD/,
|
|
)
|
|
expect(() => validateServerBind({ hostname: '0.0.0.0', password: undefined })).toThrow(
|
|
/credencial|OPENCODE_SERVER_PASSWORD/,
|
|
)
|
|
})
|
|
|
|
it('non-loopback with a password is allowed', () => {
|
|
expect(validateServerBind({ hostname: '100.119.220.31', password: 'x' })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('sessionDirFromEnv + caminhos de estado', () => {
|
|
it('TEQO_AGENT_SESSION_DIR wins; XDG_STATE_HOME; HOME fallback', () => {
|
|
expect(sessionDirFromEnv({ TEQO_AGENT_SESSION_DIR: '/tmp/runs', HOME: '/home/x' })).toBe(
|
|
'/tmp/runs',
|
|
)
|
|
expect(sessionDirFromEnv({ XDG_STATE_HOME: '/xdg', HOME: '/home/x' })).toBe(
|
|
'/xdg/teqo/agent-sessions',
|
|
)
|
|
expect(sessionDirFromEnv({ HOME: '/home/x' })).toBe('/home/x/.local/state/teqo/agent-sessions')
|
|
})
|
|
|
|
it('slugs the branch (paths with / become one filename) and derives every path', () => {
|
|
expect(sessionSlug('OPS110-acompanhar-runs')).toBe('ops110-acompanhar-runs')
|
|
expect(sessionSlug('plans/plan-issue-agenda eleitoral')).toBe(
|
|
'plans-plan-issue-agenda-eleitoral',
|
|
)
|
|
const sessionDir = '/state'
|
|
const branch = 'plans/plan-issue-agenda'
|
|
expect(sessionStatePath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.json')
|
|
expect(startLockPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.lock')
|
|
expect(driverLogPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.log')
|
|
expect(serverStatePath({ sessionDir })).toBe('/state/server.json')
|
|
})
|
|
})
|
|
|
|
describe('codeFromBranch', () => {
|
|
it('reads the uppercase code prefix of a claim branch', () => {
|
|
expect(codeFromBranch('OPS110-acompanhar-runs')).toBe('OPS110')
|
|
expect(codeFromBranch('C15-fullcalendar')).toBe('C15')
|
|
})
|
|
|
|
it('returns null for namespace branches (plan/new/fix have no claim code)', () => {
|
|
expect(codeFromBranch('plans/plan-issue-1')).toBeNull()
|
|
expect(codeFromBranch('work/ideia')).toBeNull()
|
|
expect(codeFromBranch('fix/abc')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('estado da sessão (contrato OPS109)', () => {
|
|
it('serializes and parses the documented fields (roundtrip)', () => {
|
|
const parsed = parseSessionState(serializeSessionState(state()))
|
|
expect(parsed).toEqual(state())
|
|
expect(parsed.sessionID).toMatch(/^ses/)
|
|
expect(parsed.version).toBe(STATE_VERSION)
|
|
})
|
|
|
|
it('fails high on a malformed state (never silently accepts)', () => {
|
|
expect(() => parseSessionState('not json')).toThrow(/inválido/)
|
|
expect(() => parseSessionState(JSON.stringify({ branch: 'x' }))).toThrow(/inválido/)
|
|
expect(() => parseSessionState(JSON.stringify({ ...state(), sessionID: 'nope' }))).toThrow(
|
|
/inválido/,
|
|
)
|
|
})
|
|
|
|
it('serializes/parses the server state', () => {
|
|
const server = {
|
|
version: STATE_VERSION,
|
|
url: 'http://127.0.0.1:4199',
|
|
hostname: '127.0.0.1',
|
|
port: 4199,
|
|
pid: 777,
|
|
startedAt: '2026-09-15T10:00:00.000Z',
|
|
logPath: '/state/server.log',
|
|
}
|
|
expect(parseServerState(serializeServerState(server))).toEqual(server)
|
|
expect(() => parseServerState('{}')).toThrow(/inválido/)
|
|
})
|
|
})
|
|
|
|
describe('purposeInvocation (mapa purpose→comando movido do worktree)', () => {
|
|
it('pins the skill command by purpose', () => {
|
|
expect(SESSION_COMMAND_BY_PURPOSE).toEqual({
|
|
next: 'work-issue',
|
|
plan: 'plan-issue',
|
|
new: null,
|
|
fix: 'bug-fix',
|
|
})
|
|
})
|
|
|
|
it('next carries the claimed issue as --issue <N>', () => {
|
|
expect(purposeInvocation({ purpose: 'next', issueNumber: 1019 })).toEqual({
|
|
command: 'work-issue',
|
|
arguments: '--issue 1019',
|
|
})
|
|
})
|
|
|
|
it('next without a valid issue starts no driver — nothing to auto-submit', () => {
|
|
expect(purposeInvocation({ purpose: 'next' })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'next', issueNumber: 'x' })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'next', issueNumber: 0 })).toBeNull()
|
|
})
|
|
|
|
it('plan without a bag starts no driver — the human types the context', () => {
|
|
expect(purposeInvocation({ purpose: 'plan' })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'plan', issueNumber: 7 })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'plan', argument: ' "" ' })).toBeNull()
|
|
})
|
|
|
|
it('plan carries the opening message as the sanitized bag (xargs/argv-safe)', () => {
|
|
expect(purposeInvocation({ purpose: 'plan', argument: 'revisa o gate do designer' })).toEqual({
|
|
command: 'plan-issue',
|
|
arguments: 'revisa o gate do designer',
|
|
})
|
|
expect(purposeInvocation({ purpose: 'plan', argument: 'a"b\\c' })).toEqual({
|
|
command: 'plan-issue',
|
|
arguments: 'abc',
|
|
})
|
|
})
|
|
|
|
it('new has no command at all (apenas conversar)', () => {
|
|
expect(purposeInvocation({ purpose: 'new' })).toBeNull()
|
|
})
|
|
|
|
it('fix sends the sanitized bag (xargs/argv-safe); empty bag is driverless (OPS71)', () => {
|
|
expect(purposeInvocation({ purpose: 'fix', argument: 'a"b\\c bug' })).toEqual({
|
|
command: 'bug-fix',
|
|
arguments: 'abc bug',
|
|
})
|
|
expect(purposeInvocation({ purpose: 'fix', argument: ' "" ' })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'fix' })).toBeNull()
|
|
})
|
|
|
|
it('unknown purpose degrades to no command (fail-safe direction)', () => {
|
|
expect(purposeInvocation({ purpose: 'bogus' })).toBeNull()
|
|
expect(purposeInvocation({})).toBeNull()
|
|
})
|
|
|
|
it('auto=true transposes to the exact skill flags — /work-issue --issue N --auto', () => {
|
|
expect(purposeInvocation({ purpose: 'next', issueNumber: 1088, auto: true })).toEqual({
|
|
command: 'work-issue',
|
|
arguments: '--issue 1088 --auto',
|
|
})
|
|
})
|
|
|
|
it('auto=true prefixes plan/fix bags — /plan-issue --auto <bag> and /bug-fix --auto <bag>', () => {
|
|
expect(purposeInvocation({ purpose: 'plan', argument: 'revisa o gate', auto: true })).toEqual({
|
|
command: 'plan-issue',
|
|
arguments: '--auto revisa o gate',
|
|
})
|
|
expect(purposeInvocation({ purpose: 'fix', argument: '500 no autosave', auto: true })).toEqual({
|
|
command: 'bug-fix',
|
|
arguments: '--auto 500 no autosave',
|
|
})
|
|
})
|
|
|
|
it('auto=true with an empty fix bag is driverless (OPS71)', () => {
|
|
expect(purposeInvocation({ purpose: 'fix', auto: true })).toBeNull()
|
|
})
|
|
|
|
it('auto=false (default) keeps the supervised forms byte-identical', () => {
|
|
expect(purposeInvocation({ purpose: 'next', issueNumber: 1088 })).toEqual({
|
|
command: 'work-issue',
|
|
arguments: '--issue 1088',
|
|
})
|
|
expect(purposeInvocation({ purpose: 'fix', argument: 'b' })).toMatchObject({
|
|
arguments: 'b',
|
|
})
|
|
expect(purposeInvocation({ purpose: 'fix' })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'plan', argument: 'b' })).toMatchObject({
|
|
arguments: 'b',
|
|
})
|
|
})
|
|
|
|
it('auto=true never resurrects a missing invocation (new / plan without bag stay null)', () => {
|
|
expect(purposeInvocation({ purpose: 'new', auto: true })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'plan', auto: true })).toBeNull()
|
|
expect(purposeInvocation({ purpose: 'plan', argument: ' ', auto: true })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('parseModelRef + buildCreateSessionBody (OPS122 — o modelo chega à SESSÃO)', () => {
|
|
it('splits provider/model at the FIRST slash', () => {
|
|
expect(parseModelRef('opencode/muse-spark-1.3-contributor-free')).toEqual({
|
|
providerID: 'opencode',
|
|
id: 'muse-spark-1.3-contributor-free',
|
|
})
|
|
expect(parseModelRef('openrouter/openrouter/free')).toEqual({
|
|
providerID: 'openrouter',
|
|
id: 'openrouter/free',
|
|
})
|
|
expect(parseModelRef('opencode-go/deepseek-v4.1-flash')).toEqual({
|
|
providerID: 'opencode-go',
|
|
id: 'deepseek-v4.1-flash',
|
|
})
|
|
})
|
|
|
|
it('fails high on a model without provider/id — never sends a half ref', () => {
|
|
expect(() => parseModelRef('deepseek-flash')).toThrow(/provider\/model|inválido/)
|
|
expect(() => parseModelRef('/deepseek-flash')).toThrow(/inválido/)
|
|
expect(() => parseModelRef('deepseek/')).toThrow(/inválido/)
|
|
expect(() => parseModelRef('')).toThrow()
|
|
expect(() => parseModelRef(null as unknown as string)).toThrow()
|
|
})
|
|
|
|
it('builds the POST /session body with the model ref + variant, or an empty body without one', () => {
|
|
expect(buildCreateSessionBody({ model: 'opencode-go/deepseek-v4.1-flash' })).toEqual({
|
|
model: { providerID: 'opencode-go', id: 'deepseek-v4.1-flash', variant: MODEL_VARIANT },
|
|
})
|
|
expect(buildCreateSessionBody({ model: null })).toEqual({})
|
|
expect(buildCreateSessionBody({ model: undefined })).toEqual({})
|
|
expect(buildCreateSessionBody({})).toEqual({})
|
|
expect(buildCreateSessionBody()).toEqual({})
|
|
expect(() => buildCreateSessionBody({ model: 'no-slash' })).toThrow(/inválido|provider/)
|
|
})
|
|
|
|
it('pins max as the single launch variant (OPS127 — the TUI inherits, no attach flag)', () => {
|
|
expect(MODEL_VARIANT).toBe('max')
|
|
})
|
|
})
|
|
|
|
describe('assertKnownFlags (dono único da mensagem fail-high)', () => {
|
|
it('is silent when every flag is accepted or a bare `--`', () => {
|
|
expect(() =>
|
|
assertKnownFlags({ flags: { '': true, stay: true }, accepted: ['stay'] }),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('throws with a neighbor suggestion, or as-is without one', () => {
|
|
expect(() => assertKnownFlags({ flags: { stya: true }, accepted: ['stay'] })).toThrow(
|
|
/--stya[\s\S]*--stay/,
|
|
)
|
|
expect(() => assertKnownFlags({ flags: { nope: true }, accepted: ['stay'] })).toThrow(
|
|
'flag desconhecida: --nope.',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('validateSessionFlags + nearestAcceptedFlag (OPS110-F1 — flag desconhecida falha alto)', () => {
|
|
it('pins the accepted flag names per verb', () => {
|
|
expect(SESSION_FLAG_ALLOWLIST).toEqual({
|
|
serve: ['hostname', 'port'],
|
|
start: [
|
|
'purpose',
|
|
'dir',
|
|
'model',
|
|
'issue',
|
|
'argument',
|
|
'new',
|
|
'skill-auto',
|
|
'hostname',
|
|
'port',
|
|
],
|
|
attach: ['session', 'branch', 'issue'],
|
|
stop: ['session', 'branch', 'issue'],
|
|
list: ['json', 'hostname', 'port'],
|
|
})
|
|
})
|
|
|
|
it('accepts every documented flag per verb (names only, valued or boolean)', () => {
|
|
expect(() =>
|
|
validateSessionFlags({
|
|
subcommand: 'start',
|
|
flags: {
|
|
purpose: 'next',
|
|
dir: '/d',
|
|
model: 'm',
|
|
issue: '1',
|
|
argument: 'x',
|
|
new: true,
|
|
'skill-auto': true,
|
|
hostname: 'h',
|
|
port: '1',
|
|
},
|
|
}),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'serve', flags: { hostname: 'h', port: '1' } }),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'list', flags: { json: true, hostname: 'h', port: '1' } }),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'attach', flags: { session: 'ses_x' } }),
|
|
).not.toThrow()
|
|
expect(() => validateSessionFlags({ subcommand: 'stop', flags: { branch: 'b' } })).not.toThrow()
|
|
})
|
|
|
|
it('suggests the transposition neighbor: --prupose → --purpose', () => {
|
|
expect(nearestAcceptedFlag('prupose', ['purpose', 'dir', 'model'])).toBe('purpose')
|
|
expect(() => validateSessionFlags({ subcommand: 'start', flags: { prupose: 'next' } })).toThrow(
|
|
/--prupose[\s\S]*--purpose/,
|
|
)
|
|
})
|
|
|
|
it('suggests substitution/insertion neighbors at distance 1, nothing at distance 2', () => {
|
|
expect(nearestAcceptedFlag('purposee', ['purpose'])).toBe('purpose')
|
|
expect(nearestAcceptedFlag('purpos', ['purpose'])).toBe('purpose')
|
|
expect(nearestAcceptedFlag('prpose', ['purpose'])).toBe('purpose')
|
|
expect(nearestAcceptedFlag('purposxx', ['purpose'])).toBeNull()
|
|
expect(nearestAcceptedFlag('totallyWrong', ['purpose', 'dir'])).toBeNull()
|
|
})
|
|
|
|
it('reports an unknown flag without a suggestion as-is', () => {
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'start', flags: { totallyWrong: true } }),
|
|
).toThrow('flag desconhecida: --totallyWrong.')
|
|
})
|
|
|
|
it('rejects a flag that belongs to another verb', () => {
|
|
expect(() => validateSessionFlags({ subcommand: 'serve', flags: { purpose: 'next' } })).toThrow(
|
|
/--purpose/,
|
|
)
|
|
expect(() => validateSessionFlags({ subcommand: 'list', flags: { dir: '/d' } })).toThrow(
|
|
/--dir/,
|
|
)
|
|
})
|
|
|
|
it('ignores a bare `--` (empty flag name), never a typo', () => {
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'start', flags: { '': true, purpose: 'next' } }),
|
|
).not.toThrow()
|
|
})
|
|
|
|
it('stays silent for an unknown verb (the CLI prints the USAGE itself)', () => {
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: 'bogus', flags: { whatever: true } }),
|
|
).not.toThrow()
|
|
expect(() =>
|
|
validateSessionFlags({ subcommand: null, flags: { whatever: true } }),
|
|
).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('driverArgs + attachArgs (o argv verificado ao vivo)', () => {
|
|
it('drives the run through the server with --auto and --command + `--` separator', () => {
|
|
expect(
|
|
driverArgs({
|
|
url: 'http://127.0.0.1:4199',
|
|
sessionID: 'ses_abc',
|
|
dir: '/work/OPS110',
|
|
model: 'deepseek/deepseek-flash',
|
|
invocation: { command: 'work-issue', arguments: '--issue 1019' },
|
|
}),
|
|
).toEqual([
|
|
'run',
|
|
'--attach',
|
|
'http://127.0.0.1:4199',
|
|
'-s',
|
|
'ses_abc',
|
|
'--dir',
|
|
'/work/OPS110',
|
|
'--model',
|
|
'deepseek/deepseek-flash',
|
|
'--variant',
|
|
'max',
|
|
'--auto',
|
|
'--command',
|
|
'work-issue',
|
|
'--',
|
|
'--issue 1019',
|
|
])
|
|
})
|
|
|
|
it('omits the `--` separator when the invocation carries no arguments (fix with an empty bag)', () => {
|
|
expect(
|
|
driverArgs({
|
|
url: 'http://127.0.0.1:4199',
|
|
sessionID: 'ses_abc',
|
|
dir: '/work/fix',
|
|
model: 'm',
|
|
invocation: { command: 'bug-fix', arguments: '' },
|
|
}),
|
|
).toEqual([
|
|
'run',
|
|
'--attach',
|
|
'http://127.0.0.1:4199',
|
|
'-s',
|
|
'ses_abc',
|
|
'--dir',
|
|
'/work/fix',
|
|
'--model',
|
|
'm',
|
|
'--variant',
|
|
'max',
|
|
'--auto',
|
|
'--command',
|
|
'bug-fix',
|
|
])
|
|
})
|
|
|
|
it('fails high on a missing url/session/model (never spawns half a run)', () => {
|
|
expect(() =>
|
|
driverArgs({
|
|
url: '',
|
|
sessionID: 'ses_abc',
|
|
dir: '/d',
|
|
model: 'm',
|
|
invocation: { command: 'work-issue', arguments: '' },
|
|
}),
|
|
).toThrow(/url/)
|
|
expect(() =>
|
|
driverArgs({
|
|
url: 'http://x',
|
|
sessionID: '',
|
|
dir: '/d',
|
|
model: 'm',
|
|
invocation: { command: 'work-issue', arguments: '' },
|
|
}),
|
|
).toThrow(/session/)
|
|
expect(() =>
|
|
driverArgs({
|
|
url: 'http://x',
|
|
sessionID: 'ses_abc',
|
|
dir: '/d',
|
|
model: '',
|
|
invocation: { command: 'work-issue', arguments: '' },
|
|
}),
|
|
).toThrow(/model/)
|
|
expect(() =>
|
|
driverArgs({
|
|
url: 'http://x',
|
|
sessionID: 'ses_abc',
|
|
dir: '/d',
|
|
model: 'm',
|
|
invocation: null,
|
|
}),
|
|
).toThrow(/invocation|comando/i)
|
|
})
|
|
|
|
it('fails high on a missing attach url/session/dir — never spawns a half attach', () => {
|
|
expect(() => attachArgs({ url: '', sessionID: 'ses_a', dir: '/d' })).toThrow(/url/)
|
|
expect(() => attachArgs({ url: 'http://x', sessionID: '', dir: '/d' })).toThrow(/session/)
|
|
expect(() => attachArgs({ url: 'http://x', sessionID: 'ses_a', dir: '' })).toThrow(/dir/)
|
|
})
|
|
|
|
it('attach resumes exactly the same session/dir (never passes --auto/--model/--variant)', () => {
|
|
const argv = attachArgs({ url: 'http://127.0.0.1:4199', sessionID: 'ses_abc', dir: '/work/x' })
|
|
expect(argv).toEqual(['attach', 'http://127.0.0.1:4199', '--dir', '/work/x', '-s', 'ses_abc'])
|
|
expect(argv).not.toContain('--auto')
|
|
expect(argv).not.toContain('--model')
|
|
expect(argv).not.toContain('--variant')
|
|
})
|
|
|
|
it('serverArgs is loopback-flagged and never enables --mdns', () => {
|
|
expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).toEqual([
|
|
'serve',
|
|
'--port',
|
|
'4199',
|
|
'--hostname',
|
|
'127.0.0.1',
|
|
])
|
|
expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).not.toContain('--mdns')
|
|
})
|
|
})
|
|
|
|
describe('resolveSessionRef (--session > --branch > --issue > branch do cwd)', () => {
|
|
const next = state()
|
|
const plan = state({
|
|
code: null,
|
|
issue: null,
|
|
branch: 'plans/plan-issue-agenda',
|
|
sessionID: 'ses_plan',
|
|
dir: '/work/plans/plan-issue-agenda',
|
|
})
|
|
|
|
it('resolves by session id first', () => {
|
|
expect(
|
|
resolveSessionRef({ states: [next, plan], session: 'ses_plan', branch: next.branch }),
|
|
).toBe(plan)
|
|
})
|
|
|
|
it('resolves by branch, then by issue, then by cwd branch', () => {
|
|
expect(resolveSessionRef({ states: [next, plan], branch: plan.branch })).toBe(plan)
|
|
expect(resolveSessionRef({ states: [next, plan], issue: 1019 })).toBe(next)
|
|
expect(resolveSessionRef({ states: [next, plan], cwdBranch: next.branch })).toBe(next)
|
|
})
|
|
|
|
it('returns null when nothing matches', () => {
|
|
expect(resolveSessionRef({ states: [next, plan], session: 'ses_nope' })).toBeNull()
|
|
expect(resolveSessionRef({ states: [], cwdBranch: 'x' })).toBeNull()
|
|
expect(resolveSessionRef({ states: [next, plan] })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('resolveStartDecision (OPS110-F1 — single-flight "reusar vs criar")', () => {
|
|
const liveState = state()
|
|
const base = {
|
|
statePath: '/state/OPS110.json',
|
|
serverUrl: liveState.url,
|
|
readFile: () => JSON.stringify(liveState),
|
|
exists: () => true,
|
|
probeBusy: async () => false,
|
|
driverAlive: () => false,
|
|
}
|
|
|
|
it('reuses when the driver is alive on the same server', async () => {
|
|
expect(await resolveStartDecision({ ...base, driverAlive: () => true })).toMatchObject({
|
|
action: 'reuse',
|
|
reason: 'running',
|
|
state: liveState,
|
|
})
|
|
})
|
|
|
|
it('reuses when the session is busy even with the driver gone', async () => {
|
|
expect(await resolveStartDecision({ ...base, probeBusy: async () => true })).toMatchObject({
|
|
action: 'reuse',
|
|
reason: 'running',
|
|
})
|
|
})
|
|
|
|
it('creates fresh when idle (driver dead and not busy)', async () => {
|
|
expect(await resolveStartDecision(base)).toMatchObject({ action: 'fresh', reason: 'idle' })
|
|
})
|
|
|
|
it('--new forces fresh without reading the state', async () => {
|
|
let read = false
|
|
const decision = await resolveStartDecision({
|
|
...base,
|
|
forceNew: true,
|
|
readFile: () => {
|
|
read = true
|
|
return 'ignored'
|
|
},
|
|
})
|
|
expect(decision).toMatchObject({ action: 'fresh', reason: 'forced' })
|
|
expect(read).toBe(false)
|
|
})
|
|
|
|
it('creates fresh when there is no state file', async () => {
|
|
expect(await resolveStartDecision({ ...base, exists: () => false })).toMatchObject({
|
|
action: 'fresh',
|
|
reason: 'missing',
|
|
})
|
|
})
|
|
|
|
it('creates fresh on unreadable state and surfaces the error for the CLI warning', async () => {
|
|
const decision = await resolveStartDecision({
|
|
...base,
|
|
readFile: () => {
|
|
throw new Error('boom')
|
|
},
|
|
})
|
|
expect(decision.action).toBe('fresh')
|
|
expect(decision.reason).toBe('unreadable')
|
|
expect(decision.error?.message).toBe('boom')
|
|
})
|
|
|
|
it('creates fresh when the state points at another server', async () => {
|
|
expect(await resolveStartDecision({ ...base, serverUrl: 'http://other:1' })).toMatchObject({
|
|
action: 'fresh',
|
|
reason: 'server-mismatch',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('isStaleLock (OPS110-F1 — reclaim do lock com PID morto)', () => {
|
|
it('treats a dead PID as stale', () => {
|
|
expect(isStaleLock({ holderPid: 4242, isPidAlive: () => false })).toBe(true)
|
|
})
|
|
|
|
it('keeps a live holder (not stale)', () => {
|
|
expect(isStaleLock({ holderPid: 4242, isPidAlive: () => true })).toBe(false)
|
|
})
|
|
|
|
it('treats a missing/non-numeric holder as stale (never deadlocks forever)', () => {
|
|
expect(isStaleLock({ holderPid: NaN, isPidAlive: () => true })).toBe(true)
|
|
expect(isStaleLock({ holderPid: null, isPidAlive: () => true })).toBe(true)
|
|
expect(isStaleLock({ holderPid: 0, isPidAlive: () => true })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('deriveSessionStatus + list (contrato OPS109)', () => {
|
|
it('stopped > unknown (server down) > working (busy or driver alive) > idle', () => {
|
|
const live = state()
|
|
expect(
|
|
deriveSessionStatus({
|
|
state: live,
|
|
serverHealthy: true,
|
|
sessionBusy: true,
|
|
driverAlive: false,
|
|
}),
|
|
).toBe('working')
|
|
expect(
|
|
deriveSessionStatus({
|
|
state: live,
|
|
serverHealthy: true,
|
|
sessionBusy: false,
|
|
driverAlive: true,
|
|
}),
|
|
).toBe('working')
|
|
expect(
|
|
deriveSessionStatus({
|
|
state: live,
|
|
serverHealthy: true,
|
|
sessionBusy: false,
|
|
driverAlive: false,
|
|
}),
|
|
).toBe('idle')
|
|
expect(
|
|
deriveSessionStatus({
|
|
state: live,
|
|
serverHealthy: false,
|
|
sessionBusy: false,
|
|
driverAlive: false,
|
|
}),
|
|
).toBe('unknown')
|
|
const stopped = state({ stoppedAt: '2026-09-15T11:00:00.000Z' })
|
|
expect(
|
|
deriveSessionStatus({
|
|
state: stopped,
|
|
serverHealthy: true,
|
|
sessionBusy: true,
|
|
driverAlive: true,
|
|
}),
|
|
).toBe('stopped')
|
|
})
|
|
|
|
it('list payload carries the versioned contract with the derived status', () => {
|
|
const payload = sessionListPayload({
|
|
states: [state()],
|
|
statuses: { ses_f5b6ec763ffeTquA6nLR2XVxz3: 'working' },
|
|
server: { url: 'http://127.0.0.1:4199', healthy: true, version: '1.18.31' },
|
|
})
|
|
expect(payload.version).toBe(STATE_VERSION)
|
|
expect(payload.server).toEqual({
|
|
url: 'http://127.0.0.1:4199',
|
|
healthy: true,
|
|
version: '1.18.31',
|
|
})
|
|
expect(payload.sessions).toHaveLength(1)
|
|
expect(payload.sessions[0]).toMatchObject({
|
|
sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3',
|
|
issue: 1019,
|
|
status: 'working',
|
|
})
|
|
})
|
|
|
|
it('list payload marks a session without a known status as unknown (never lies)', () => {
|
|
const payload = sessionListPayload({
|
|
states: [state()],
|
|
server: { url: 'http://127.0.0.1:4199', healthy: false, version: null },
|
|
})
|
|
expect(payload.sessions[0].status).toBe('unknown')
|
|
})
|
|
|
|
it('human list prints one line per run with status/session/branch/dir/log', () => {
|
|
const lines = formatSessionList([
|
|
{
|
|
status: 'working',
|
|
code: 'OPS110',
|
|
issue: 1019,
|
|
branch: 'OPS110-acompanhar-runs',
|
|
sessionID: 'ses_abc',
|
|
dir: '/work/OPS110',
|
|
logPath: '/state/ops110-acompanhar-runs.log',
|
|
},
|
|
{
|
|
status: 'idle',
|
|
code: null,
|
|
issue: null,
|
|
branch: 'plans/plan-issue-1',
|
|
sessionID: 'ses_def',
|
|
dir: '/work/plans/plan-issue-1',
|
|
},
|
|
])
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines[0]).toContain('working')
|
|
expect(lines[0]).toContain('OPS110')
|
|
expect(lines[0]).toContain('#1019')
|
|
expect(lines[0]).toContain('ses_abc')
|
|
expect(lines[0]).toContain('/work/OPS110')
|
|
expect(lines[0]).toContain('/state/ops110-acompanhar-runs.log')
|
|
expect(lines[1]).toContain('plans/plan-issue-1')
|
|
expect(lines[1]).not.toContain('#null')
|
|
expect(lines[1].endsWith('—')).toBe(true)
|
|
})
|
|
})
|