The desktop app could only queue while busy — `/steer` was in the palette but had no first-class affordance, so the "nudge the agent mid-turn without interrupting" lane was effectively unreachable. Add a steer action to the composer: while busy with a text-only draft, a steering-wheel button (and Cmd/Ctrl+Enter) injects the text into the live turn via the `session.steer` RPC — the gateway folds it into the next tool result so the model reads it on its next iteration. Plain Enter still queues. steerPrompt returns false when the gateway has no live tool window (or the RPC errors), and the composer re-queues the words so nothing is lost — the same safety net as a plain queue.
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { cleanup, render } from '@testing-library/react'
|
|
import type { MutableRefObject } from 'react'
|
|
import { useEffect } from 'react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { $sessions, setSessions } from '@/store/session'
|
|
import type { SessionInfo } from '@/types/hermes'
|
|
|
|
import { usePromptActions } from './use-prompt-actions'
|
|
|
|
vi.mock('@/hermes', () => ({
|
|
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
|
setApiRequestProfile: vi.fn(),
|
|
transcribeAudio: vi.fn()
|
|
}))
|
|
|
|
// The active id the desktop holds is the *runtime* session id from
|
|
// session.create — deliberately distinct from the stored DB id here, because
|
|
// that mismatch is the bug: the REST renameSession endpoint resolves against
|
|
// the stored sessions table and 404s on a runtime id. session.title accepts
|
|
// the runtime id directly.
|
|
const RUNTIME_SESSION_ID = 'rt-abc123'
|
|
|
|
function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|
return {
|
|
ended_at: null,
|
|
id: RUNTIME_SESSION_ID,
|
|
input_tokens: 0,
|
|
is_active: true,
|
|
last_active: 0,
|
|
message_count: 3,
|
|
model: null,
|
|
output_tokens: 0,
|
|
preview: null,
|
|
source: null,
|
|
started_at: 0,
|
|
title: 'Old title',
|
|
tool_call_count: 0,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
interface HarnessHandle {
|
|
steerPrompt: (text: string) => Promise<boolean>
|
|
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
|
}
|
|
|
|
function Harness({
|
|
busyRef,
|
|
onReady,
|
|
onSeedState,
|
|
refreshSessions,
|
|
requestGateway
|
|
}: {
|
|
busyRef?: MutableRefObject<boolean>
|
|
onReady: (handle: HarnessHandle) => void
|
|
onSeedState?: (state: Record<string, unknown>) => void
|
|
refreshSessions: () => Promise<void>
|
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
}) {
|
|
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
|
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
|
const localBusyRef = busyRef ?? { current: false }
|
|
|
|
const actions = usePromptActions({
|
|
activeSessionId: RUNTIME_SESSION_ID,
|
|
activeSessionIdRef,
|
|
branchCurrentSession: async () => true,
|
|
busyRef: localBusyRef,
|
|
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
|
handleSkinCommand: () => '',
|
|
refreshSessions,
|
|
requestGateway,
|
|
selectedStoredSessionIdRef,
|
|
startFreshSessionDraft: () => undefined,
|
|
sttEnabled: false,
|
|
updateSessionState: (_sessionId, updater) => {
|
|
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
|
const next = updater({
|
|
messages: [],
|
|
busy: false,
|
|
awaitingResponse: false,
|
|
interrupted: true
|
|
} as never) as unknown as Record<string, unknown>
|
|
onSeedState?.(next)
|
|
|
|
return next as never
|
|
}
|
|
})
|
|
|
|
useEffect(() => {
|
|
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
|
}, [actions.steerPrompt, actions.submitText, onReady])
|
|
|
|
return null
|
|
}
|
|
|
|
describe('usePromptActions /title', () => {
|
|
beforeEach(() => {
|
|
setSessions(() => [sessionInfo()])
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
const requestGateway = vi.fn(async (method: string) =>
|
|
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
|
|
)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
|
|
|
await handle!.submitText('/title New title')
|
|
|
|
// Routes through session.title with the runtime session id — NOT the slash
|
|
// worker (slash.exec) and NOT the REST endpoint. This is the path that
|
|
// resolves the runtime id and persists reliably across platforms.
|
|
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
|
session_id: RUNTIME_SESSION_ID,
|
|
title: 'New title'
|
|
})
|
|
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
expect($sessions.get()[0]?.title).toBe('New title')
|
|
})
|
|
|
|
it('reports the queued state when the session row is not persisted yet', async () => {
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
const requestGateway = vi.fn(async (method: string) =>
|
|
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
|
|
)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
|
|
|
await handle!.submitText('/title Fresh chat')
|
|
|
|
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
|
session_id: RUNTIME_SESSION_ID,
|
|
title: 'Fresh chat'
|
|
})
|
|
// Even when queued, the sidebar reflects the chosen title optimistically.
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
expect($sessions.get()[0]?.title).toBe('Fresh chat')
|
|
})
|
|
|
|
it('falls through to the slash worker for a bare /title (show current title)', async () => {
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
|
|
|
await handle!.submitText('/title')
|
|
|
|
expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything())
|
|
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' }))
|
|
})
|
|
|
|
it('surfaces a rename error without touching the sidebar store', async () => {
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
const requestGateway = vi.fn(async (method: string) => {
|
|
if (method === 'session.title') {
|
|
throw new Error('Title too long')
|
|
}
|
|
|
|
return {} as never
|
|
})
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
|
|
|
await handle!.submitText('/title way too long title')
|
|
|
|
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
|
|
expect(refreshSessions).not.toHaveBeenCalled()
|
|
expect($sessions.get()[0]?.title).toBe('Old title')
|
|
})
|
|
})
|
|
|
|
describe('usePromptActions submit / queue drain semantics', () => {
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('clears a leftover interrupted flag on a fresh submit (so the new turn streams)', async () => {
|
|
const seeds: Record<string, unknown>[] = []
|
|
const requestGateway = vi.fn(async () => ({}) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(
|
|
<Harness
|
|
onReady={h => (handle = h)}
|
|
onSeedState={s => seeds.push(s)}
|
|
refreshSessions={async () => undefined}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
)
|
|
|
|
await handle!.submitText('hello after a stop')
|
|
|
|
// The optimistic seed must reset interrupted:false even though the prior
|
|
// session state had interrupted:true — otherwise the message stream drops
|
|
// every delta of this brand-new turn.
|
|
expect(seeds.length).toBeGreaterThan(0)
|
|
expect(seeds.every(s => s.interrupted === false)).toBe(true)
|
|
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
|
session_id: RUNTIME_SESSION_ID,
|
|
text: 'hello after a stop'
|
|
})
|
|
})
|
|
|
|
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
|
|
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
|
|
// drained queue send would otherwise hit the busy guard and silently no-op.
|
|
const busyRef = { current: true }
|
|
const requestGateway = vi.fn(async () => ({}) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(
|
|
<Harness
|
|
busyRef={busyRef}
|
|
onReady={h => (handle = h)}
|
|
refreshSessions={async () => undefined}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
)
|
|
|
|
const accepted = await handle!.submitText('queued message', { fromQueue: true })
|
|
|
|
expect(accepted).toBe(true)
|
|
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
|
session_id: RUNTIME_SESSION_ID,
|
|
text: 'queued message'
|
|
})
|
|
})
|
|
|
|
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
|
const busyRef = { current: true }
|
|
const requestGateway = vi.fn(async () => ({}) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(
|
|
<Harness
|
|
busyRef={busyRef}
|
|
onReady={h => (handle = h)}
|
|
refreshSessions={async () => undefined}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
)
|
|
|
|
const accepted = await handle!.submitText('should be blocked')
|
|
|
|
expect(accepted).toBe(false)
|
|
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
|
})
|
|
})
|
|
|
|
describe('usePromptActions steerPrompt', () => {
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => {
|
|
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
|
|
|
const accepted = await handle!.steerPrompt(' nudge the run ')
|
|
|
|
expect(accepted).toBe(true)
|
|
// Steer never starts a turn — it rides the live run via session.steer only.
|
|
expect(requestGateway).toHaveBeenCalledWith('session.steer', {
|
|
session_id: RUNTIME_SESSION_ID,
|
|
text: 'nudge the run'
|
|
})
|
|
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
|
})
|
|
|
|
it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => {
|
|
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
|
|
|
expect(await handle!.steerPrompt('too late')).toBe(false)
|
|
})
|
|
|
|
it('reports rejection (never throws) when the steer RPC errors', async () => {
|
|
const requestGateway = vi.fn(async () => {
|
|
throw new Error('agent does not support steer')
|
|
})
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
|
|
|
expect(await handle!.steerPrompt('boom')).toBe(false)
|
|
})
|
|
|
|
it('skips the RPC entirely for empty text', async () => {
|
|
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
|
|
|
let handle: HarnessHandle | null = null
|
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
|
|
|
expect(await handle!.steerPrompt(' ')).toBe(false)
|
|
expect(requestGateway).not.toHaveBeenCalled()
|
|
})
|
|
})
|