Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/desktop/e2e/context-meter-spawn.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* The statusbar context meter has to track a session started by
* `hermes desktop spawn`, not just one the user typed.
*
* Usage used to reach the renderer only at turn boundaries — `session.info`
* just before `message.start`, `message.complete` at the end. A chat someone
* types turns over often enough that the gauge looks live; a spawned
* `--delegated` run is ONE long agentic turn, so nothing arrived between its
* two ends and the meter sat frozen for the whole run. The gateway now also
* pushes `token.usage` as each tool completes.
*
* The mock backend is told to take its time over each tool round trip so a
* single turn lasts long enough to sample; without that the turn finishes
* inside one poll and the test could pass without the fix.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/

import fs from 'node:fs'
import path from 'node:path'

import { expect, test } from '@playwright/test'

import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'

/** Long enough that one turn spans several polls below. */
const TOOL_ROUND_TRIP_MS = 900

let fixture: MockBackendFixture | null = null

test.beforeAll(async () => {
process.env.MOCK_TOOL_CALL_DELAY_MS = String(TOOL_ROUND_TRIP_MS)
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})

test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
delete process.env.MOCK_TOOL_CALL_DELAY_MS
})

/**
* The context readout, e.g. "20k/256k [█░░░░░░░░░] 8%" — '' when absent.
*
* Read off the item's own button, not the statusbar's concatenated text: the
* items render flush against each other, so the neighbouring subagent counter
* turns "16.3k/256k" into an unparseable "0316.3k/256k".
*/
async function meterText(): Promise<string> {
return (
(await fixture!.page.evaluate(() => {
const bar = document.querySelector('[data-slot="statusbar"]')

for (const button of Array.from(bar?.querySelectorAll('button') ?? [])) {
const spans = Array.from(button.querySelectorAll(':scope > span')).map(span => span.textContent ?? '')

if (spans.some(span => /^[\d.]+[kmb]?\/[\d.]+[kmb]?$/i.test(span.trim()))) {
return spans.join(' ').replace(/\s+/g, ' ').trim()
}
}

return ''
})) ?? ''
)
}

/** Poll the meter, returning each DISTINCT value it took, in order. */
async function meterSeries(label: string, seconds: number): Promise<string[]> {
const series: string[] = []

for (let i = 0; i < seconds * 5; i += 1) {
const value = await meterText()

if (series[series.length - 1] !== value) {
series.push(value)
console.log(` [${label}] t=${(i / 5).toFixed(1)}s meter="${value}"`)
}

await fixture!.page.waitForTimeout(200)
}

return series
}

/**
* Read the spawn control channel the app published under HERMES_HOME.
*
* Polled: the app writes this once its control server is listening, which can
* land after the window is otherwise ready.
*/
async function readControlFile(): Promise<{ port: number; token: string }> {
const controlPath = path.join(fixture!.sandbox.hermesHome, 'desktop', 'control.json')

for (let i = 0; i < 60; i += 1) {
try {
const parsed = JSON.parse(fs.readFileSync(controlPath, 'utf8'))

if (parsed?.port && parsed?.token) {
return { port: Number(parsed.port), token: String(parsed.token) }
}
} catch {
// not written yet, or written half — retry
}

await fixture!.page.waitForTimeout(500)
}

throw new Error(`no usable control.json at ${controlPath} after 30s`)
}

async function postSpawn(prompt: string, extra: Record<string, unknown> = {}): Promise<number> {
const { port, token } = await readControlFile()

const res = await fetch(`http://127.0.0.1:${port}/spawn`, {
body: JSON.stringify({ prompt, ...extra }),
headers: { 'Content-Type': 'application/json', 'X-Hermes-Desktop-Token': token },
method: 'POST'
})

return res.status
}

/** Values the gauge actually showed — drops the empty reading a new chat starts at. */
function gaugeReadings(series: string[]): string[] {
return series.filter(Boolean)
}

test.describe('statusbar context meter', () => {
test('tracks a spawned session while its single turn is still running', async () => {
expect(await postSpawn('Use your tools and then report back.', { delegated: true })).toBe(202)

const series = await meterSeries('spawn', 20)
const readings = gaugeReadings(series)

console.log('SPAWN readings:', readings)

// Before the mid-turn `token.usage` frames existed this was at most one
// reading, published by message.complete once the whole turn had finished.
expect(readings.length).toBeGreaterThanOrEqual(2)
// A real gauge, not a bare token count: "16.3k/256k [█░░░░░░░░░] 6%".
expect(readings[0]).toMatch(/^[\d.]+[kmb]?\/[\d.]+[kmb]?\s+\[[█░]+\]\s+\d+%$/i)
})

test('still tracks a session the user typed', async () => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()

await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await page.keyboard.insertText('Use your tools and then report back.')
await page.keyboard.press('Enter')

const readings = gaugeReadings(await meterSeries('typed', 20))

console.log('TYPED readings:', readings)

expect(readings.length).toBeGreaterThanOrEqual(2)
})
})
169 changes: 165 additions & 4 deletions apps/desktop/e2e/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,74 @@
* GET /v1/models → { data: [{ id, ... }] }
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
*
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated — the E2E tests only need the chat surface to
* prove the full boot → gateway → inference → renderer chain works.
* The canned response is a short, deterministic assistant message. If the
* request advertises tools and fewer than MAX_TOOL_CALLS `tool` messages
* have come back yet, the server instead makes another tool call
* (preferring a `todo` tool, else the first tool offered) with minimal
* arguments derived from its JSON schema, so an E2E test can observe several
* round trips within a single agent turn. Once enough tool results have
* round-tripped back as `role: 'tool'` messages, the server falls through to
* the canned reply below, ending the turn.
*/

import http from 'node:http'

/** A canned assistant reply used for every chat completion request. */
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'

/** More than one: several round trips in a single turn let a test watch mid-turn state change. */
const MAX_TOOL_CALLS = 3

/**
* Pause before answering a tool-call request, in ms (MOCK_TOOL_CALL_DELAY_MS).
*
* Off by default so the ordinary specs stay fast. A test that has to observe
* state changing *during* a turn needs that turn to outlast its polling
* interval — otherwise the whole turn lands between two samples and the test
* would pass whether or not the behaviour it checks is there.
*/
function toolCallDelayMs(): number {
const raw = Number(process.env.MOCK_TOOL_CALL_DELAY_MS)

return Number.isFinite(raw) && raw > 0 ? raw : 0
}

/** A trivially valid value for a JSON schema property, honoring `enum` and `type`. */
function trivialValueFor(propSchema: any): unknown {
if (Array.isArray(propSchema?.enum) && propSchema.enum.length > 0) {
return propSchema.enum[0]
}

switch (propSchema?.type) {
case 'string':
return 'e2e'
case 'number':
case 'integer':
return 1
case 'boolean':
return true
case 'array':
return []
case 'object':
return {}
default:
return 'e2e'
}
}

/** Build minimal valid arguments for a tool call from its JSON schema. */
function buildToolArguments(schema: any): Record<string, unknown> {
const properties = schema?.properties || {}
const required: string[] = Array.isArray(schema?.required) ? schema.required : []
const args: Record<string, unknown> = {}

for (const key of required) {
args[key] = trivialValueFor(properties[key])
}

return args
}

/**
* Start the mock server on an ephemeral port.
*
Expand Down Expand Up @@ -66,7 +124,7 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
body += chunk.toString()
})

req.on('end', () => {
req.on('end', async () => {
let parsed: any = {}

try {
Expand All @@ -78,6 +136,109 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'

const messages = Array.isArray(parsed.messages) ? parsed.messages : []
const tools = Array.isArray(parsed.tools) ? parsed.tools : []
const toolResultCount = messages.filter((message: any) => message?.role === 'tool').length
const shouldCallTool = toolResultCount < MAX_TOOL_CALLS && tools.length > 0

if (shouldCallTool) {
const tool = tools.find((t: any) => t?.function?.name === 'todo') || tools[0]
const toolName = tool?.function?.name || 'unknown'
const toolArgs = buildToolArguments(tool?.function?.parameters)
const toolCallId = `mock-tool-call-${toolResultCount + 1}`
const argsJson = JSON.stringify(toolArgs)
const delayMs = toolCallDelayMs()

if (delayMs > 0) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs))
}

if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})

// One chunk carries the whole tool call, then a final chunk
// closes the turn with finish_reason: 'tool_calls'.
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: toolCallId,
type: 'function',
function: { name: toolName, arguments: argsJson },
},
],
},
finish_reason: null,
},
],
})}\n\n`,
)
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
} else {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: toolCallId,
type: 'function',
function: { name: toolName, arguments: argsJson },
},
],
},
finish_reason: 'tool_calls',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
return
}

if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/lib/token-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ describe('usageFromTokenUsagePayload', () => {
})
})

it('carries the compression count so a mid-turn compaction can lower the gauge', () => {
// A long agentic turn can compress before it ends. Without the count the
// monotonic guard in mergeUsageSnapshot reads the smaller window as a
// regression and pins the meter high for the rest of the turn.
const beforeCompaction = mergeTokenUsagePayload(
{ calls: 4, input: 0, output: 0, total: 0 },
{ compressions: 0, context_length: 262_144, context_pct: 82, context_tokens: 215_000 }
)

expect(
mergeTokenUsagePayload(beforeCompaction, {
compressions: 1,
context_length: 262_144,
context_pct: 24,
context_tokens: 63_000
})
).toMatchObject({ compressions: 1, context_percent: 24, context_used: 63_000 })
})

it('derives context percent when the backend omits context_pct', () => {
expect(
usageFromTokenUsagePayload({
Expand Down
Loading
Loading