Files
aiturk-hermes-ide/apps/desktop/src/store/goals.test.ts
T

163 lines
5.3 KiB
TypeScript

import { JsonRpcGatewayError } from '@hermes/shared'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import { $goalsBySession, applyGoalStatusText, clearSessionGoal, refreshSessionGoal } from './goals'
import { resetBackgroundPollingGuard } from './runtime-gone'
describe('goal store', () => {
afterEach(() => {
vi.useRealTimers()
$goalsBySession.set({})
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('stores active goals from /goal output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
expect($goalsBySession.get().s1).toMatchObject({
status: 'active',
title: 'ship the feature'
})
})
it('keeps the current title for continuation and pause messages', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
applyGoalStatusText('s1', '↻ Continuing toward goal (1/20): next step is tests')
expect($goalsBySession.get().s1).toMatchObject({
detail: 'Continuing toward goal (1/20): next step is tests',
status: 'active',
title: 'ship the feature'
})
applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.')
expect($goalsBySession.get().s1).toMatchObject({
status: 'paused',
title: 'ship the feature'
})
})
it('lingers done goals before clearing them', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
applyGoalStatusText('s1', '✓ Goal achieved: tests pass')
expect($goalsBySession.get().s1).toMatchObject({ status: 'done' })
vi.advanceTimersByTime(7_999)
expect($goalsBySession.get().s1).toBeTruthy()
vi.advanceTimersByTime(1)
expect($goalsBySession.get().s1).toBeUndefined()
})
it('clears on no-goal output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature')
applyGoalStatusText('s1', 'No active goal. Set one with /goal <text>.')
expect($goalsBySession.get().s1).toBeUndefined()
})
it('clears immediately on /goal clear output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature')
applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.')
applyGoalStatusText('s1', '✓ Goal cleared.')
expect($goalsBySession.get().s1).toBeUndefined()
})
it('cancels pending done clears when replacing a goal', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '⊙ Goal set: first')
applyGoalStatusText('s1', '✓ Goal achieved: first done')
applyGoalStatusText('s1', '⊙ Goal set: second')
vi.advanceTimersByTime(8_000)
expect($goalsBySession.get().s1).toMatchObject({ status: 'active', title: 'second' })
clearSessionGoal('s1')
})
it('does not resurrect a done goal on hydration', () => {
// `/goal status` reports "✓ Goal done" forever after completion (the DB
// keeps terminal state). Hydrating that on session open must not bring
// the completed chip back — Bot Mode's single endless session would show
// it for the rest of time.
applyGoalStatusText('s1', '✓ Goal done (12/20 turns): ship the feature', { hydrate: true })
expect($goalsBySession.get().s1).toBeUndefined()
})
it('hydration drops a lingering done chip already on screen', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '✓ Goal achieved: tests pass')
applyGoalStatusText('s1', '✓ Goal done (12/20 turns): tests pass', { hydrate: true })
expect($goalsBySession.get().s1).toBeUndefined()
})
it('hydration still surfaces non-terminal goals', () => {
applyGoalStatusText('s1', '⊙ Goal (active, 3/20 turns): ship the feature', { hydrate: true })
expect($goalsBySession.get().s1).toMatchObject({ status: 'active', title: 'ship the feature' })
applyGoalStatusText('s2', '⏸ Goal (paused, 20/20 turns): other work', { hydrate: true })
expect($goalsBySession.get().s2).toMatchObject({ status: 'paused', title: 'other work' })
})
it('does not retry goal hydration for a runtime rejected as session-not-found', async () => {
const request = vi.fn(async () => {
throw new JsonRpcGatewayError('session not found', { code: 4001 })
})
$gateway.set({ request } as never)
await refreshSessionGoal('dead-runtime')
await refreshSessionGoal('dead-runtime')
expect(request).toHaveBeenCalledTimes(1)
})
})
describe('refreshSessionGoal dead-session guard', () => {
afterEach(() => {
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('stops re-asking a runtime the gateway no longer holds', async () => {
const request = vi.fn(async () => {
throw new Error('session not found')
})
$gateway.set({ request } as never)
await refreshSessionGoal('dead-1')
await refreshSessionGoal('dead-1')
await refreshSessionGoal('dead-1')
expect(request).toHaveBeenCalledTimes(1)
})
it('does not latch on a transient failure', async () => {
const request = vi.fn(async () => {
throw new Error('request timed out after 30s: slash.exec')
})
$gateway.set({ request } as never)
await refreshSessionGoal('s1')
await refreshSessionGoal('s1')
expect(request).toHaveBeenCalledTimes(2)
})
})