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
47 changes: 47 additions & 0 deletions apps/desktop/e2e/context-meter-spawn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import path from 'node:path'

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

import { compactNumber } from '../src/lib/format'

import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { probeRoundPromptCount, resetProbeRoundPromptCount } from './mock-server'

/**
* Long enough that one turn spans several polls below, and longer than the
Expand Down Expand Up @@ -197,4 +200,48 @@ test.describe('statusbar context meter', () => {
expect(readings.length).toBeGreaterThanOrEqual(2)
expectNeverDips(readings)
})

/**
* The round the whole change is about.
*
* Every round above calls a tool, so sampling occupancy per completed TOOL
* and sampling it per API RESPONSE produce the same series — those tests
* cannot tell the two apart. Here one mid-turn round names a tool that does
* not exist: the agent rejects the name before the executor, so the round
* finishes a real API call, reports real usage, and completes no tool.
*
* Sampling on tool completion has nothing to report for it, and the meter
* skips that value entirely. Sampling on the response reports it.
*/
test('reports a round that finished an API call but completed no tool', async () => {
resetProbeRoundPromptCount()
process.env.MOCK_TOOL_FREE_ROUND = '1'

try {
expect(await postSpawn('Use your tools and then report back.', { delegated: true })).toBe(202)

const readings = gaugeReadings(await meterSeries('tool-free', 25))
const probeTokens = probeRoundPromptCount()

console.log('TOOL-FREE readings:', readings, '| probe round served:', probeTokens)

// Guards the test itself: if the mock never reached the probe round there
// is nothing to assert about, and a bare `toContain` would just fail with
// a confusing message about the wrong thing.
expect(probeTokens, 'the mock never served the tool-free round').not.toBeNull()
expectNeverDips(readings)

// Formatted through the status bar's own formatter rather than compared
// as a number: the gauge is what the user reads, and a literal here would
// drift the moment the prompt or tool list changes size.
const expectedLabel = `${compactNumber(probeTokens)}/`

expect(
readings.some(reading => reading.startsWith(expectedLabel)),
`the tool-free round never reached the meter — wanted ${expectedLabel} in ${readings.join(' -> ')}`
).toBe(true)
} finally {
delete process.env.MOCK_TOOL_FREE_ROUND
}
})
})
84 changes: 79 additions & 5 deletions apps/desktop/e2e/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain
/** More than one: several round trips in a single turn let a test watch mid-turn state change. */
const MAX_TOOL_CALLS = 3

/**
* A tool name the agent cannot possibly have.
*
* Naming one is how this mock produces a round that finishes a real API call —
* usage and all — without any tool ever running: the agent rejects the name
* before it reaches the executor, appends a result saying so, and keeps going.
* That is the one round shape where reporting occupancy per API response
* differs from reporting it per completed tool.
*
* `zz_`-prefixed and deliberately unlike any real tool: the agent repairs a
* near-miss name by fuzzy match, and a name that got repaired into a REAL tool
* would execute, complete, and quietly turn this back into an ordinary round.
*/
export const PROBE_TOOL_NAME = 'zz_e2e_probe_missing'
const PROBE_CALL_ID = 'mock-probe-1'

/** Opt-in (MOCK_TOOL_FREE_ROUND): off by default so the other specs are untouched. */
function toolFreeRoundEnabled(): boolean {
const raw = process.env.MOCK_TOOL_FREE_ROUND

return raw === '1' || raw === 'true'
}

let probeRoundPromptTokens: number | null = null

/**
* The prompt count served on the tool-free round, or null if it hasn't run.
*
* Read by the spec instead of hardcoding a number: the usage schedule is a
* function of conversation length, so a literal in the test would drift the
* moment the system prompt or tool list changes size.
*/
export function probeRoundPromptCount(): number | null {
return probeRoundPromptTokens
}

export function resetProbeRoundPromptCount(): void {
probeRoundPromptTokens = null
}

/**
* Pause before answering a tool-call request, in ms (MOCK_TOOL_CALL_DELAY_MS).
*
Expand Down Expand Up @@ -177,14 +217,48 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (

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

// Count only THIS turn's rounds, not the whole conversation. Counting
// every `tool` message ever sent meant a second turn in the same chat
// started already over budget and collapsed to a single round — so a
// test that meant to watch a multi-round turn silently watched a
// one-round one.
const lastUserIndex = messages.map((message: any) => message?.role).lastIndexOf('user')
const turnToolResults = messages
.slice(lastUserIndex + 1)
.filter((message: any) => message?.role === 'tool').length

if (toolFreeRoundEnabled() && tools.some((t: any) => t?.function?.name === PROBE_TOOL_NAME)) {
// The probe only works while the agent has no such tool. If one ever
// appears under this name it would really execute and complete, and
// the round would quietly stop being tool-free — the test would go
// on passing while testing nothing. Answer with an error instead of
// throwing: this runs inside an async request handler, where a throw
// becomes an unhandled rejection in the test process rather than a
// legible failure, and leaves the request hanging.
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
error: { message: `${PROBE_TOOL_NAME} is a real tool — pick a name the agent cannot have` },
}),
)
return
}

// One round mid-turn names a tool that does not exist. It is a full
// API round trip that reports usage and completes no tool.
const shouldProbe = toolFreeRoundEnabled() && turnToolResults === 1 && tools.length > 0
const shouldCallTool = shouldProbe || (turnToolResults < 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 toolName = shouldProbe ? PROBE_TOOL_NAME : tool?.function?.name || 'unknown'
const toolArgs = shouldProbe ? {} : buildToolArguments(tool?.function?.parameters)
const toolCallId = shouldProbe ? PROBE_CALL_ID : `mock-tool-call-${turnToolResults + 1}`

if (shouldProbe) {
probeRoundPromptTokens = usageFor(messages).prompt_tokens
}
const argsJson = JSON.stringify(toolArgs)
const delayMs = toolCallDelayMs()

Expand Down
Loading