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 .') 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) }) })