diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 5884fd8e9..78f0e8434 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -116,6 +116,16 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [ }, ]; +const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [ + { + id: "load-test", + label: "Load Test", + description: "Streams repeated markdown, reasoning, and tool calls for app stress testing", + icon: "ShieldOff", + colorTier: "dangerous", + }, +]; + export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ { id: "claude", @@ -168,9 +178,23 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ }, ]; +export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ + { + id: "mock", + label: "Mock Load Test", + description: + "Development-only provider that emits synthetic agent traffic for performance tests", + defaultModeId: "load-test", + modes: MOCK_LOAD_TEST_MODES, + }, +]; + export function getAgentProviderDefinition( provider: string, - definitions: AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS, + definitions: AgentProviderDefinition[] = [ + ...AGENT_PROVIDER_DEFINITIONS, + ...DEV_AGENT_PROVIDER_DEFINITIONS, + ], ): AgentProviderDefinition { const definition = definitions.find((entry) => entry.id === provider); if (!definition) { diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 445b121c2..10d00e1c2 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -332,6 +332,19 @@ describe("buildProviderRegistry", () => { expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length); }); + test("includes mock provider only for development builds", () => { + expect(buildProviderRegistry(logger).mock).toBeUndefined(); + expect(buildProviderRegistry(logger, { isDev: false }).mock).toBeUndefined(); + + const registry = buildProviderRegistry(logger, { isDev: true }); + + expect(registry.mock).toMatchObject({ + id: "mock", + label: "Mock Load Test", + defaultModeId: "load-test", + }); + }); + test("built-in override applies command", () => { buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 224b0a79f..ff7abe48d 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -27,9 +27,11 @@ import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { PiDirectAgentClient } from "./providers/pi-direct-agent.js"; +import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { AGENT_PROVIDER_DEFINITIONS, BUILTIN_PROVIDER_IDS, + DEV_AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition, type AgentProviderDefinition, } from "./provider-manifest.js"; @@ -48,6 +50,7 @@ export type BuildProviderRegistryOptions = { runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; workspaceGitService?: Pick; + isDev?: boolean; }; type ProviderClientFactory = ( @@ -85,6 +88,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { logger, runtimeSettings, }), + mock: (logger) => new MockLoadTestAgentClient(logger), }; function getProviderClientFactory(provider: string): ProviderClientFactory { @@ -349,10 +353,15 @@ function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, options: Pick, + isDev: boolean, ): Map { const resolvedProviders = new Map(); - for (const definition of AGENT_PROVIDER_DEFINITIONS) { + const definitions = isDev + ? [...AGENT_PROVIDER_DEFINITIONS, ...DEV_AGENT_PROVIDER_DEFINITIONS] + : AGENT_PROVIDER_DEFINITIONS; + + for (const definition of definitions) { const override = providerOverrides[definition.id]; const factory = getProviderClientFactory(definition.id); const mergedRuntimeSettings = mergeRuntimeSettings( @@ -380,7 +389,7 @@ function addDerivedProviders( providerOverrides: Record, ): void { for (const [providerId, override] of Object.entries(providerOverrides)) { - if (BUILTIN_PROVIDER_IDS.includes(providerId)) { + if (resolvedProviders.has(providerId) || BUILTIN_PROVIDER_IDS.includes(providerId)) { continue; } @@ -448,9 +457,14 @@ export function buildProviderRegistry( ): Record { const runtimeSettings = options?.runtimeSettings; const providerOverrides = options?.providerOverrides ?? {}; - const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings, { - workspaceGitService: options?.workspaceGitService, - }); + const resolvedProviders = buildResolvedBuiltinProviders( + providerOverrides, + runtimeSettings, + { + workspaceGitService: options?.workspaceGitService, + }, + options?.isDev === true, + ); addDerivedProviders(resolvedProviders, providerOverrides); return Object.fromEntries( diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts new file mode 100644 index 000000000..96ce497d0 --- /dev/null +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -0,0 +1,176 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { AgentManager } from "../agent-manager.js"; +import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; +import { + MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + MockLoadTestAgentClient, +} from "./mock-load-test-agent.js"; + +describe("MockLoadTestAgentClient", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("default model is a five minute foreground stream", async () => { + const client = new MockLoadTestAgentClient(); + + const models = await client.listModels(); + + expect(models[0]).toMatchObject({ + id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + isDefault: true, + metadata: { + durationMs: 300_000, + intervalMs: 1000, + }, + }); + }); + + test("emits repeated markdown, reasoning, and tool calls during a foreground turn", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + const resultPromise = session.run("Exercise the app while terminals are busy."); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + const result = await resultPromise; + unsubscribe(); + + expect( + events.map((event) => event.type).filter((type) => type === "turn_started"), + ).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "mock", + }); + expect(result).toMatchObject({ + sessionId: session.id, + finalText: "Synthetic load test complete", + canceled: false, + }); + + const timelineItems = events.flatMap((event): AgentTimelineItem[] => + event.type === "timeline" ? [event.item] : [], + ); + expect(timelineItems.find((item) => item.type === "reasoning")).toMatchObject({ + type: "reasoning", + text: expect.stringContaining("Thinking chunk 1"), + }); + expect( + timelineItems.filter( + (item) => + item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle 1\n"), + ), + ).toHaveLength(1); + expect( + timelineItems.filter((item) => item.type === "assistant_message" && item.text.length > 0) + .length, + ).toBeGreaterThan(4); + expect( + timelineItems.filter( + (item) => + item.type === "tool_call" && + item.name === "edit" && + item.status === "running" && + item.detail.type === "edit", + ), + ).toHaveLength(40); + expect( + timelineItems.filter( + (item) => + item.type === "tool_call" && + item.name === "bash" && + item.status === "completed" && + item.detail.type === "shell" && + item.detail.output?.includes("mock load cycle"), + ), + ).toHaveLength(40); + }); + + test("interrupt cancels the active foreground turn and stops future chunks", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Cancel the synthetic stream."); + await vi.advanceTimersByTimeAsync(0); + + await session.interrupt(); + const eventCountAfterInterrupt = events.length; + await vi.advanceTimersByTimeAsync(10_000); + unsubscribe(); + + expect(events.at(-1)).toMatchObject({ + type: "turn_canceled", + provider: "mock", + reason: "Interrupted", + }); + expect(events).toHaveLength(eventCountAfterInterrupt); + }); + + test("agent manager coalesces adjacent markdown chunks from the mock provider", async () => { + vi.useFakeTimers(); + const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-")); + try { + const client = new MockLoadTestAgentClient(); + const manager = new AgentManager({ + clients: { mock: client }, + idFactory: () => "00000000-0000-4000-8000-000000000001", + logger: createTestLogger(), + }); + const agent = await manager.createAgent( + { + provider: "mock", + cwd: workdir, + model: "ten-second-stream", + }, + "00000000-0000-4000-8000-000000000001", + ); + + const resultPromise = manager.runAgent( + agent.id, + "Stress the agent stream while terminal panes are active.", + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + await resultPromise; + + const timeline = manager.getTimeline(agent.id); + const loadCycleDocuments = timeline.filter( + (item): item is Extract => + item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle "), + ); + const editToolCalls = timeline.filter( + (item) => item.type === "tool_call" && item.name === "edit", + ); + + expect(loadCycleDocuments).toHaveLength(40); + expect(loadCycleDocuments[0]?.text).toContain("This paragraph is intentionally stable"); + expect(editToolCalls).toHaveLength(40); + expect( + editToolCalls.every((item) => item.type === "tool_call" && item.status === "completed"), + ).toBe(true); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts new file mode 100644 index 000000000..350b41c3a --- /dev/null +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -0,0 +1,549 @@ +import { randomUUID } from "node:crypto"; +import type { Logger } from "pino"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentFeature, + AgentLaunchContext, + AgentMode, + AgentModelDefinition, + AgentPersistenceHandle, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPermissionResult, + AgentPromptInput, + AgentProvider, + AgentRunOptions, + AgentRunResult, + AgentRuntimeInfo, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + ListModelsOptions, + ListPersistedAgentsOptions, + PersistedAgentDescriptor, + ToolCallTimelineItem, +} from "../agent-sdk-types.js"; +import { getAgentProviderDefinition } from "../provider-manifest.js"; + +export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; +export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream"; +const MOCK_LOAD_TEST_MODE_ID = "load-test"; +const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; +const MOCK_LOAD_TEST_INTERVAL_MS = 1000; + +const CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const MODELS: AgentModelDefinition[] = [ + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + label: "Five minute stream", + description: "Repeats synthetic markdown and tool-call timeline traffic for five minutes.", + isDefault: true, + metadata: { + durationMs: MOCK_LOAD_TEST_DURATION_MS, + intervalMs: MOCK_LOAD_TEST_INTERVAL_MS, + }, + }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "one-minute-stream", + label: "One minute stream", + description: "Shorter synthetic load stream for quick manual checks.", + metadata: { + durationMs: 60_000, + intervalMs: 500, + }, + }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "ten-second-stream", + label: "Ten second stream", + description: "Fast synthetic load stream for tests and smoke checks.", + metadata: { + durationMs: 10_000, + intervalMs: 250, + }, + }, +]; + +type ActiveTurn = { + turnId: string; + prompt: AgentPromptInput; + startedAt: number; + iteration: number; + durationMs: number; + intervalMs: number; + timer: ReturnType | null; + resolve: (result: AgentRunResult) => void; + completed: Promise; +}; + +function resolveModelProfile(modelId: string | null | undefined): { + modelId: string; + durationMs: number; + intervalMs: number; +} { + const model = MODELS.find((entry) => entry.id === modelId) ?? MODELS[0]!; + const metadata = model.metadata ?? {}; + return { + modelId: model.id, + durationMs: + typeof metadata.durationMs === "number" ? metadata.durationMs : MOCK_LOAD_TEST_DURATION_MS, + intervalMs: + typeof metadata.intervalMs === "number" ? metadata.intervalMs : MOCK_LOAD_TEST_INTERVAL_MS, + }; +} + +function promptToText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") { + return prompt; + } + return prompt + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n") + .trim(); +} + +function buildMarkdownDocument(iteration: number, prompt: AgentPromptInput): string { + const promptPreview = promptToText(prompt).slice(0, 160) || "No prompt text supplied."; + return [ + `## Synthetic Load Cycle ${iteration}`, + "", + `Prompt preview: ${promptPreview}`, + "", + "| Signal | Value |", + "| --- | --- |", + `| cycle | ${iteration} |`, + "| stream | markdown + reasoning + tools |", + "", + "```ts", + `const cycle = ${iteration};`, + 'const purpose = "exercise app parsing and terminal rendering under load";', + "```", + "", + "This paragraph is intentionally stable so repeated cycles produce predictable UI pressure.", + "", + ].join("\n"); +} + +function splitText(text: string, chunks: number): string[] { + const size = Math.ceil(text.length / chunks); + const parts: string[] = []; + for (let index = 0; index < text.length; index += size) { + parts.push(text.slice(index, index + size)); + } + return parts; +} + +function createToolCall(input: { + turnId: string; + iteration: number; + name: string; + status: ToolCallTimelineItem["status"]; + detail: ToolCallTimelineItem["detail"]; +}): ToolCallTimelineItem { + return { + type: "tool_call", + callId: `${input.turnId}:${input.name}:${input.iteration}`, + name: input.name, + status: input.status, + error: null, + detail: input.detail, + }; +} + +export class MockLoadTestAgentClient implements AgentClient { + readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; + readonly capabilities = CAPABILITIES; + + constructor(private readonly logger?: Logger) {} + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + return new MockLoadTestAgentSession({ + config, + sessionId: randomUUID(), + logger: this.logger, + }); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + const metadata = (handle.metadata ?? {}) as Partial; + return new MockLoadTestAgentSession({ + config: { + cwd: String(metadata.cwd ?? overrides?.cwd ?? process.cwd()), + ...metadata, + ...overrides, + provider: MOCK_LOAD_TEST_PROVIDER_ID, + }, + sessionId: handle.sessionId, + logger: this.logger, + }); + } + + async listModels(_options?: ListModelsOptions): Promise { + return MODELS; + } + + async listModes(): Promise { + return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; + } + + async listPersistedAgents( + _options?: ListPersistedAgentsOptions, + ): Promise { + return []; + } + + async isAvailable(): Promise { + return true; + } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + return { + diagnostic: "Mock load-test provider is available in development builds.", + }; + } +} + +export class MockLoadTestAgentSession implements AgentSession { + readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; + readonly capabilities = CAPABILITIES; + readonly features: AgentFeature[] = []; + readonly id: string; + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private readonly logger?: Logger; + private activeTurn: ActiveTurn | null = null; + private modeId: string | null; + private modelId: string | null; + + constructor(options: { + config: AgentSessionConfig; + sessionId: string; + logger?: Logger; + }) { + this.id = options.sessionId; + this.logger = options.logger; + this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID; + this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID; + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const { turnId } = await this.startTurn(prompt, options); + const turn = this.activeTurn; + if (!turn || turn.turnId !== turnId) { + throw new Error("Mock load-test turn did not start"); + } + return turn.completed; + } + + async startTurn( + prompt: AgentPromptInput, + _options?: AgentRunOptions, + ): Promise<{ turnId: string }> { + if (this.activeTurn) { + throw new Error("Mock load-test provider already has an active turn"); + } + + const profile = resolveModelProfile(this.modelId); + const turnId = randomUUID(); + let resolve!: (result: AgentRunResult) => void; + const completed = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + const turn: ActiveTurn = { + turnId, + prompt, + startedAt: Date.now(), + iteration: 0, + durationMs: profile.durationMs, + intervalMs: profile.intervalMs, + timer: null, + resolve, + completed, + }; + this.activeTurn = turn; + + this.schedule(turn, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + for (const event of this.history) { + yield event; + } + } + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.id, + model: this.modelId, + modeId: this.modeId, + }; + } + + async getAvailableModes(): Promise { + return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; + } + + async getCurrentMode(): Promise { + return this.modeId; + } + + async setMode(modeId: string): Promise { + this.modeId = modeId; + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission( + _requestId: string, + _response: AgentPermissionResponse, + ): Promise { + return undefined; + } + + describePersistence(): AgentPersistenceHandle | null { + return { + provider: this.provider, + sessionId: this.id, + metadata: { + model: this.modelId, + modeId: this.modeId, + }, + }; + } + + async interrupt(): Promise { + const turn = this.activeTurn; + if (!turn) { + return; + } + this.clearTurnTimer(turn); + this.activeTurn = null; + const event: AgentStreamEvent = { + type: "turn_canceled", + provider: this.provider, + reason: "Interrupted", + turnId: turn.turnId, + }; + this.emit(event); + turn.resolve({ + sessionId: this.id, + finalText: "", + timeline: [], + canceled: true, + }); + } + + async close(): Promise { + await this.interrupt(); + this.listeners.clear(); + } + + async setModel(modelId: string | null): Promise { + this.modelId = modelId ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID; + } + + private schedule(turn: ActiveTurn, delayMs: number): void { + turn.timer = setTimeout(() => { + this.tick(turn); + }, delayMs); + turn.timer.unref?.(); + } + + private tick(turn: ActiveTurn): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + if (turn.iteration === 0) { + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + } + + const elapsedMs = Date.now() - turn.startedAt; + if (elapsedMs >= turn.durationMs) { + this.finishTurn(turn); + return; + } + + turn.iteration += 1; + this.emitIteration(turn); + this.schedule(turn, turn.intervalMs); + } + + private emitIteration(turn: ActiveTurn): void { + const { turnId, iteration, prompt } = turn; + this.emitTimeline(turnId, { + type: "reasoning", + text: `Thinking chunk ${iteration}: simulate planning pressure before rendering markdown.\n`, + }); + + for (const chunk of splitText(buildMarkdownDocument(iteration, prompt), 4)) { + this.emitTimeline(turnId, { + type: "assistant_message", + text: chunk, + }); + } + + const editDetail = { + type: "edit" as const, + filePath: `src/load-test-${iteration}.ts`, + oldString: "before", + newString: "after", + unifiedDiff: [ + `diff --git a/src/load-test-${iteration}.ts b/src/load-test-${iteration}.ts`, + "@@", + "-before", + "+after", + ].join("\n"), + }; + this.emitTimeline( + turnId, + createToolCall({ + turnId, + iteration, + name: "edit", + status: "running", + detail: editDetail, + }), + ); + this.emitTimeline( + turnId, + createToolCall({ + turnId, + iteration, + name: "edit", + status: "completed", + detail: editDetail, + }), + ); + + const shellDetail = { + type: "shell" as const, + command: `printf 'mock load cycle ${iteration}\\n'`, + cwd: "/tmp/paseo-mock-load", + output: `mock load cycle ${iteration}\n`, + exitCode: 0, + }; + this.emitTimeline( + turnId, + createToolCall({ + turnId, + iteration, + name: "bash", + status: "running", + detail: shellDetail, + }), + ); + this.emitTimeline( + turnId, + createToolCall({ + turnId, + iteration, + name: "bash", + status: "completed", + detail: shellDetail, + }), + ); + + this.emit({ + type: "usage_updated", + provider: this.provider, + turnId, + usage: { + inputTokens: iteration * 32, + outputTokens: iteration * 128, + contextWindowUsedTokens: iteration * 160, + contextWindowMaxTokens: 128_000, + }, + }); + } + + private finishTurn(turn: ActiveTurn): void { + this.activeTurn = null; + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: "## Synthetic load test complete\n\nThe mock provider finished its foreground turn.\n", + }); + const usage = { + inputTokens: turn.iteration * 32, + outputTokens: turn.iteration * 128, + contextWindowUsedTokens: turn.iteration * 160, + contextWindowMaxTokens: 128_000, + }; + this.emit({ + type: "turn_completed", + provider: this.provider, + turnId: turn.turnId, + usage, + }); + turn.resolve({ + sessionId: this.id, + finalText: "Synthetic load test complete", + usage, + timeline: [], + canceled: false, + }); + } + + private emitTimeline(turnId: string, item: AgentTimelineItem): void { + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item, + }); + } + + private emit(event: AgentStreamEvent): void { + this.history.push(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch (error) { + this.logger?.warn({ err: error }, "Mock load-test listener failed"); + } + } + } + + private clearTurnTimer(turn: ActiveTurn): void { + if (!turn.timer) { + return; + } + clearTimeout(turn.timer); + turn.timer = null; + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5c9cfd737..6485b1cc0 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -180,6 +180,7 @@ export type PaseoDaemonConfig = { mcpInjectIntoAgents?: boolean; staticDir: string; mcpDebug: boolean; + isDev?: boolean; agentClients: Partial>; agentStoragePath: string; relayEnabled?: boolean; @@ -425,6 +426,7 @@ export async function createPaseoDaemon( runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, workspaceGitService, + isDev: config.isDev === true, }), ...config.agentClients, }, @@ -435,6 +437,7 @@ export async function createPaseoDaemon( runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, workspaceGitService, + isDev: config.isDev === true, }); const detachAgentStoragePersistence = attachAgentStoragePersistence( @@ -707,6 +710,7 @@ export async function createPaseoDaemon( }, config.agentProviderSettings, config.providerOverrides, + config.isDev === true, daemonVersion, (intent) => { try { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 3115e63d7..e839fc1cc 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -181,6 +181,7 @@ export function loadConfig( mcpEnabled, mcpInjectIntoAgents, mcpDebug: env.MCP_DEBUG === "1", + isDev: env.NODE_ENV === "development", agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2ee7ed660..35c5e090b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -526,6 +526,7 @@ export type SessionOptions = { }; agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; + isDev?: boolean; }; export type SessionLifecycleIntent = @@ -720,6 +721,7 @@ export class Session { private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; + private readonly isDev: boolean; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; @@ -761,6 +763,7 @@ export class Session { dictation, agentProviderRuntimeSettings, providerOverrides, + isDev, } = options; this.clientId = clientId; this.appVersion = appVersion ?? null; @@ -829,11 +832,13 @@ export class Session { this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; + this.isDev = isDev === true; this.abortController = new AbortController(); this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, workspaceGitService: this.workspaceGitService, + isDev: this.isDev, }); // Initialize per-session managers diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index b130e4dbb..e0145d3e4 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -86,6 +86,7 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, + false, "1.2.3-test", undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 84ce0b535..bcded16c3 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -181,6 +181,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu undefined, undefined, undefined, + false, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.runtime-metrics.test.ts b/packages/server/src/server/websocket-server.runtime-metrics.test.ts index 40251d9df..bf225adee 100644 --- a/packages/server/src/server/websocket-server.runtime-metrics.test.ts +++ b/packages/server/src/server/websocket-server.runtime-metrics.test.ts @@ -94,6 +94,7 @@ function createServer(logger: ReturnType) { undefined, undefined, undefined, + false, "1.2.3-test", undefined, undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a4c0bdebc..3a5e52a40 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -319,6 +319,7 @@ export class VoiceAssistantWebSocketServer { private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; + private readonly isDev: boolean; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onBranchChanged: @@ -374,6 +375,7 @@ export class VoiceAssistantWebSocketServer { }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, providerOverrides?: Record, + isDev?: boolean, daemonVersion?: string, onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, @@ -432,11 +434,13 @@ export class VoiceAssistantWebSocketServer { this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; + this.isDev = isDev === true; const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); this.providerSnapshotManager = new ProviderSnapshotManager( buildProviderRegistry(providerSnapshotLogger, { runtimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, + isDev: this.isDev, }), providerSnapshotLogger, ); @@ -793,6 +797,7 @@ export class VoiceAssistantWebSocketServer { : undefined, agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, + isDev: this.isDev, }); connection = {