mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor(tui): store-driven turn state + slash registry + module split
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.
This commit is contained in:
parent
9c71f3a6ea
commit
68ecdb6e26
56 changed files with 3666 additions and 4117 deletions
|
|
@ -2,115 +2,55 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
|
||||
import { resetOverlayState } from '../app/overlayStore.js'
|
||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { resetTurnState } from '../app/turnStore.js'
|
||||
import { resetUiState } from '../app/uiStore.js'
|
||||
import { estimateTokensRough } from '../lib/text.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
|
||||
const buildCtx = (appended: Msg[]) =>
|
||||
({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<null | number>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() },
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
}
|
||||
}) as any
|
||||
|
||||
describe('createGatewayEventHandler', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
})
|
||||
|
||||
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
|
||||
const appended: Msg[] = []
|
||||
|
||||
const state = {
|
||||
activity: [] as unknown[],
|
||||
reasoningTokens: 0,
|
||||
streaming: '',
|
||||
toolTokens: 0,
|
||||
tools: [] as unknown[],
|
||||
turnTrail: [] as string[]
|
||||
}
|
||||
|
||||
const setTools = vi.fn((next: unknown) => {
|
||||
if (typeof next !== 'function') {
|
||||
state.tools = next as unknown[]
|
||||
}
|
||||
})
|
||||
|
||||
const setTurnTrail = vi.fn((next: unknown) => {
|
||||
if (typeof next !== 'function') {
|
||||
state.turnTrail = next as string[]
|
||||
}
|
||||
})
|
||||
|
||||
const refs = {
|
||||
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
|
||||
bufRef: ref(''),
|
||||
interruptedRef: ref(false),
|
||||
lastStatusNoteRef: ref(''),
|
||||
persistedToolLabelsRef: ref(new Set<string>()),
|
||||
protocolWarnedRef: ref(false),
|
||||
reasoningRef: ref('mapped the page'),
|
||||
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
|
||||
toolTokenAccRef: ref(0),
|
||||
toolCompleteRibbonRef: ref(null),
|
||||
turnToolsRef: ref([] as string[])
|
||||
}
|
||||
|
||||
const onEvent = createGatewayEventHandler({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<number | null>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() } as any,
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
clearReasoning: vi.fn(() => {
|
||||
refs.reasoningRef.current = ''
|
||||
refs.toolTokenAccRef.current = 0
|
||||
state.toolTokens = 0
|
||||
}),
|
||||
endReasoningPhase: vi.fn(),
|
||||
idle: vi.fn(() => {
|
||||
refs.activeToolsRef.current = []
|
||||
state.tools = []
|
||||
}),
|
||||
pruneTransient: vi.fn(),
|
||||
pulseReasoningStreaming: vi.fn(),
|
||||
pushActivity: vi.fn(),
|
||||
pushTrail: vi.fn(),
|
||||
scheduleReasoning: vi.fn(),
|
||||
scheduleStreaming: vi.fn(),
|
||||
setActivity: vi.fn(),
|
||||
setReasoningTokens: vi.fn((next: number) => {
|
||||
state.reasoningTokens = next
|
||||
}),
|
||||
setStreaming: vi.fn((next: string) => {
|
||||
state.streaming = next
|
||||
}),
|
||||
setToolTokens: vi.fn((next: number) => {
|
||||
state.toolTokens = next
|
||||
}),
|
||||
setTools,
|
||||
setTurnTrail
|
||||
},
|
||||
refs
|
||||
}
|
||||
} as any)
|
||||
turnController.reasoningText = 'mapped the page'
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
onEvent({
|
||||
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
|
||||
|
|
@ -143,104 +83,14 @@ describe('createGatewayEventHandler', () => {
|
|||
it('keeps tool tokens across handler recreation mid-turn', () => {
|
||||
const appended: Msg[] = []
|
||||
|
||||
const state = {
|
||||
activity: [] as unknown[],
|
||||
reasoningTokens: 0,
|
||||
streaming: '',
|
||||
toolTokens: 0,
|
||||
tools: [] as unknown[],
|
||||
turnTrail: [] as string[]
|
||||
}
|
||||
turnController.reasoningText = 'mapped the page'
|
||||
|
||||
const refs = {
|
||||
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
|
||||
bufRef: ref(''),
|
||||
interruptedRef: ref(false),
|
||||
lastStatusNoteRef: ref(''),
|
||||
persistedToolLabelsRef: ref(new Set<string>()),
|
||||
protocolWarnedRef: ref(false),
|
||||
reasoningRef: ref('mapped the page'),
|
||||
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
|
||||
toolTokenAccRef: ref(0),
|
||||
toolCompleteRibbonRef: ref(null),
|
||||
turnToolsRef: ref([] as string[])
|
||||
}
|
||||
|
||||
const buildHandler = () =>
|
||||
createGatewayEventHandler({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<number | null>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() } as any,
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
clearReasoning: vi.fn(() => {
|
||||
refs.reasoningRef.current = ''
|
||||
refs.toolTokenAccRef.current = 0
|
||||
state.toolTokens = 0
|
||||
}),
|
||||
endReasoningPhase: vi.fn(),
|
||||
idle: vi.fn(() => {
|
||||
refs.activeToolsRef.current = []
|
||||
state.tools = []
|
||||
}),
|
||||
pruneTransient: vi.fn(),
|
||||
pulseReasoningStreaming: vi.fn(),
|
||||
pushActivity: vi.fn(),
|
||||
pushTrail: vi.fn(),
|
||||
scheduleReasoning: vi.fn(),
|
||||
scheduleStreaming: vi.fn(),
|
||||
setActivity: vi.fn(),
|
||||
setReasoningTokens: vi.fn((next: number) => {
|
||||
state.reasoningTokens = next
|
||||
}),
|
||||
setStreaming: vi.fn((next: string) => {
|
||||
state.streaming = next
|
||||
}),
|
||||
setToolTokens: vi.fn((next: number) => {
|
||||
state.toolTokens = next
|
||||
}),
|
||||
setTools: vi.fn((next: unknown) => {
|
||||
if (typeof next !== 'function') {
|
||||
state.tools = next as unknown[]
|
||||
}
|
||||
}),
|
||||
setTurnTrail: vi.fn((next: unknown) => {
|
||||
if (typeof next !== 'function') {
|
||||
state.turnTrail = next as string[]
|
||||
}
|
||||
})
|
||||
},
|
||||
refs
|
||||
}
|
||||
} as any)
|
||||
|
||||
buildHandler()({
|
||||
createGatewayEventHandler(buildCtx(appended))({
|
||||
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
|
||||
type: 'tool.start'
|
||||
} as any)
|
||||
|
||||
const onEvent = buildHandler()
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
onEvent({
|
||||
payload: { name: 'search', preview: 'hero cards' },
|
||||
|
|
@ -265,84 +115,11 @@ describe('createGatewayEventHandler', () => {
|
|||
const streamed = 'short streamed reasoning'
|
||||
const fallback = 'x'.repeat(400)
|
||||
|
||||
const refs = {
|
||||
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
|
||||
bufRef: ref(''),
|
||||
interruptedRef: ref(false),
|
||||
lastStatusNoteRef: ref(''),
|
||||
persistedToolLabelsRef: ref(new Set<string>()),
|
||||
protocolWarnedRef: ref(false),
|
||||
reasoningRef: ref(''),
|
||||
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
|
||||
toolTokenAccRef: ref(0),
|
||||
toolCompleteRibbonRef: ref(null),
|
||||
turnToolsRef: ref([] as string[])
|
||||
}
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
const onEvent = createGatewayEventHandler({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<number | null>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() } as any,
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
clearReasoning: vi.fn(() => {
|
||||
refs.reasoningRef.current = ''
|
||||
refs.toolTokenAccRef.current = 0
|
||||
}),
|
||||
endReasoningPhase: vi.fn(),
|
||||
idle: vi.fn(() => {
|
||||
refs.activeToolsRef.current = []
|
||||
}),
|
||||
pruneTransient: vi.fn(),
|
||||
pulseReasoningStreaming: vi.fn(),
|
||||
pushActivity: vi.fn(),
|
||||
pushTrail: vi.fn(),
|
||||
scheduleReasoning: vi.fn(),
|
||||
scheduleStreaming: vi.fn(),
|
||||
setActivity: vi.fn(),
|
||||
setReasoningTokens: vi.fn(),
|
||||
setStreaming: vi.fn(),
|
||||
setToolTokens: vi.fn(),
|
||||
setTools: vi.fn(),
|
||||
setTurnTrail: vi.fn()
|
||||
},
|
||||
refs
|
||||
}
|
||||
} as any)
|
||||
|
||||
onEvent({
|
||||
payload: { text: streamed },
|
||||
type: 'reasoning.delta'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: fallback },
|
||||
type: 'reasoning.available'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: 'final answer' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
|
||||
onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
|
||||
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.thinking).toBe(streamed)
|
||||
|
|
@ -353,173 +130,12 @@ describe('createGatewayEventHandler', () => {
|
|||
const appended: Msg[] = []
|
||||
const fromServer = 'recovered from last_reasoning'
|
||||
|
||||
const refs = {
|
||||
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
|
||||
bufRef: ref(''),
|
||||
interruptedRef: ref(false),
|
||||
lastStatusNoteRef: ref(''),
|
||||
persistedToolLabelsRef: ref(new Set<string>()),
|
||||
protocolWarnedRef: ref(false),
|
||||
reasoningRef: ref(''),
|
||||
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
|
||||
toolTokenAccRef: ref(0),
|
||||
toolCompleteRibbonRef: ref(null),
|
||||
turnToolsRef: ref([] as string[])
|
||||
}
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
const onEvent = createGatewayEventHandler({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<number | null>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() } as any,
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
clearReasoning: vi.fn(() => {
|
||||
refs.reasoningRef.current = ''
|
||||
refs.toolTokenAccRef.current = 0
|
||||
}),
|
||||
endReasoningPhase: vi.fn(),
|
||||
idle: vi.fn(() => {
|
||||
refs.activeToolsRef.current = []
|
||||
}),
|
||||
pruneTransient: vi.fn(),
|
||||
pulseReasoningStreaming: vi.fn(),
|
||||
pushActivity: vi.fn(),
|
||||
pushTrail: vi.fn(),
|
||||
scheduleReasoning: vi.fn(),
|
||||
scheduleStreaming: vi.fn(),
|
||||
setActivity: vi.fn(),
|
||||
setReasoningTokens: vi.fn(),
|
||||
setStreaming: vi.fn(),
|
||||
setToolTokens: vi.fn(),
|
||||
setTools: vi.fn(),
|
||||
setTurnTrail: vi.fn()
|
||||
},
|
||||
refs
|
||||
}
|
||||
} as any)
|
||||
|
||||
onEvent({
|
||||
payload: { reasoning: fromServer, text: 'final answer' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.thinking).toBe(fromServer)
|
||||
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
||||
})
|
||||
|
||||
it('merges message.complete usage into existing context fields', () => {
|
||||
const appended: Msg[] = []
|
||||
|
||||
patchUiState({
|
||||
usage: {
|
||||
calls: 1,
|
||||
context_max: 100_000,
|
||||
context_percent: 12,
|
||||
context_used: 12_000,
|
||||
input: 10,
|
||||
output: 20,
|
||||
total: 30
|
||||
}
|
||||
})
|
||||
|
||||
const refs = {
|
||||
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
|
||||
bufRef: ref(''),
|
||||
interruptedRef: ref(false),
|
||||
lastStatusNoteRef: ref(''),
|
||||
persistedToolLabelsRef: ref(new Set<string>()),
|
||||
protocolWarnedRef: ref(false),
|
||||
reasoningRef: ref(''),
|
||||
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
|
||||
toolTokenAccRef: ref(0),
|
||||
toolCompleteRibbonRef: ref(null),
|
||||
turnToolsRef: ref([] as string[])
|
||||
}
|
||||
|
||||
const onEvent = createGatewayEventHandler({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<number | null>(null),
|
||||
sendQueued: vi.fn()
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: vi.fn() } as any,
|
||||
rpc: vi.fn(async () => null)
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: vi.fn()
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
setHistoryItems: vi.fn()
|
||||
},
|
||||
turn: {
|
||||
actions: {
|
||||
clearReasoning: vi.fn(() => {
|
||||
refs.reasoningRef.current = ''
|
||||
}),
|
||||
endReasoningPhase: vi.fn(),
|
||||
idle: vi.fn(),
|
||||
pruneTransient: vi.fn(),
|
||||
pulseReasoningStreaming: vi.fn(),
|
||||
pushActivity: vi.fn(),
|
||||
pushTrail: vi.fn(),
|
||||
scheduleReasoning: vi.fn(),
|
||||
scheduleStreaming: vi.fn(),
|
||||
setActivity: vi.fn(),
|
||||
setReasoningTokens: vi.fn(),
|
||||
setStreaming: vi.fn(),
|
||||
setToolTokens: vi.fn(),
|
||||
setTools: vi.fn(),
|
||||
setTurnTrail: vi.fn()
|
||||
},
|
||||
refs
|
||||
}
|
||||
} as any)
|
||||
|
||||
onEvent({
|
||||
payload: {
|
||||
text: 'ok',
|
||||
usage: { calls: 2, input: 50, output: 60, total: 110 }
|
||||
},
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
const u = getUiState().usage
|
||||
expect(u.input).toBe(50)
|
||||
expect(u.total).toBe(110)
|
||||
expect(u.context_max).toBe(100_000)
|
||||
expect(u.context_used).toBe(12_000)
|
||||
expect(u.context_percent).toBe(12)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue