Simplify workspace reconnect gating
This commit is contained in:
@@ -16,7 +16,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("default model is a five minute foreground stream", async () => {
|
||||
test("default model is a five minute foreground stream with token-rate intervals", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
|
||||
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
||||
@@ -26,12 +26,12 @@ describe("MockLoadTestAgentClient", () => {
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
durationMs: 300_000,
|
||||
intervalMs: 1000,
|
||||
intervalMs: 40,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("emits repeated markdown, reasoning, and tool calls during a foreground turn", async () => {
|
||||
test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
@@ -65,39 +65,43 @@ describe("MockLoadTestAgentClient", () => {
|
||||
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"),
|
||||
|
||||
const assistantTokens = timelineItems.filter((item) => item.type === "assistant_message");
|
||||
const reasoningTokens = timelineItems.filter((item) => item.type === "reasoning");
|
||||
const toolCalls = timelineItems.filter((item) => item.type === "tool_call");
|
||||
|
||||
// Many small token deltas, not a few big chunks.
|
||||
expect(assistantTokens.length).toBeGreaterThan(200);
|
||||
expect(reasoningTokens.length).toBeGreaterThan(20);
|
||||
|
||||
// Average token length should be sub-word (a few characters).
|
||||
const avgTokenLength =
|
||||
assistantTokens.reduce(
|
||||
(sum, item) => sum + (item.type === "assistant_message" ? item.text.length : 0),
|
||||
0,
|
||||
) / assistantTokens.length;
|
||||
expect(avgTokenLength).toBeLessThan(10);
|
||||
|
||||
// First assistant token starts the cycle header.
|
||||
expect(assistantTokens[0]).toMatchObject({
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining("##"),
|
||||
});
|
||||
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);
|
||||
|
||||
// Sequential tool calls fire: read, grep, edit, bash.
|
||||
const toolNames = toolCalls
|
||||
.filter((item) => item.type === "tool_call" && item.status === "running")
|
||||
.map((item) => (item.type === "tool_call" ? item.name : ""));
|
||||
expect(toolNames.slice(0, 4)).toEqual(["read", "grep", "edit", "bash"]);
|
||||
|
||||
// Each tool transitions running → completed.
|
||||
const completedNames = toolCalls
|
||||
.filter((item) => item.type === "tool_call" && item.status === "completed")
|
||||
.map((item) => (item.type === "tool_call" ? item.name : ""));
|
||||
expect(completedNames).toContain("read");
|
||||
expect(completedNames).toContain("grep");
|
||||
expect(completedNames).toContain("edit");
|
||||
expect(completedNames).toContain("bash");
|
||||
});
|
||||
|
||||
test("interrupt cancels the active foreground turn and stops future chunks", async () => {
|
||||
@@ -127,7 +131,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
expect(events).toHaveLength(eventCountAfterInterrupt);
|
||||
});
|
||||
|
||||
test("agent manager coalesces adjacent markdown chunks from the mock provider", async () => {
|
||||
test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
||||
try {
|
||||
@@ -155,20 +159,36 @@ describe("MockLoadTestAgentClient", () => {
|
||||
await resultPromise;
|
||||
|
||||
const timeline = manager.getTimeline(agent.id);
|
||||
const loadCycleDocuments = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle "),
|
||||
);
|
||||
const editToolCalls = timeline.filter(
|
||||
(item) => item.type === "tool_call" && item.name === "edit",
|
||||
);
|
||||
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
||||
const toolCalls = timeline.filter((item) => item.type === "tool_call");
|
||||
|
||||
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);
|
||||
// The provider streams sub-word tokens; the coalescer batches them within
|
||||
// its flush window, so the timeline must contain materially fewer messages
|
||||
// than the underlying token deltas would suggest, and each message must
|
||||
// hold multiple tokens worth of text.
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
const totalAssistantChars = assistantMessages.reduce(
|
||||
(sum, item) => sum + (item.type === "assistant_message" ? item.text.length : 0),
|
||||
0,
|
||||
);
|
||||
const avgMessageLength = totalAssistantChars / assistantMessages.length;
|
||||
expect(avgMessageLength).toBeGreaterThan(8);
|
||||
const longestMessage = assistantMessages
|
||||
.map((item) => (item.type === "assistant_message" ? item.text.length : 0))
|
||||
.reduce((max, length) => Math.max(max, length), 0);
|
||||
expect(longestMessage).toBeGreaterThan(20);
|
||||
|
||||
// First message includes the cycle header.
|
||||
expect(assistantMessages[0]).toMatchObject({
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining("## Cycle 1"),
|
||||
});
|
||||
|
||||
// Tool calls land in expected order at least once.
|
||||
const runningTools = toolCalls
|
||||
.filter((item) => item.type === "tool_call" && item.status === "completed")
|
||||
.map((item) => (item.type === "tool_call" ? item.name : ""));
|
||||
expect(runningTools).toEqual(expect.arrayContaining(["read", "grep", "edit", "bash"]));
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { getAgentProviderDefinition } from "../provider-manifest.js";
|
||||
@@ -32,7 +33,7 @@ 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 MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -48,31 +49,42 @@ 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.",
|
||||
description:
|
||||
"Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).",
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "thirty-minute-stream",
|
||||
label: "Thirty minute stream",
|
||||
description: "Long-running realistic stream for extended scroll-anchor debugging.",
|
||||
metadata: {
|
||||
durationMs: 30 * 60 * 1000,
|
||||
intervalMs: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "one-minute-stream",
|
||||
label: "One minute stream",
|
||||
description: "Shorter synthetic load stream for quick manual checks.",
|
||||
description: "Shorter realistic stream for quick manual checks.",
|
||||
metadata: {
|
||||
durationMs: 60_000,
|
||||
intervalMs: 500,
|
||||
intervalMs: 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "ten-second-stream",
|
||||
label: "Ten second stream",
|
||||
description: "Fast synthetic load stream for tests and smoke checks.",
|
||||
description: "Fast realistic stream for tests and smoke checks.",
|
||||
metadata: {
|
||||
durationMs: 10_000,
|
||||
intervalMs: 250,
|
||||
intervalMs: 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -81,14 +93,24 @@ interface ActiveTurn {
|
||||
turnId: string;
|
||||
prompt: AgentPromptInput;
|
||||
startedAt: number;
|
||||
iteration: number;
|
||||
cycle: number;
|
||||
durationMs: number;
|
||||
intervalMs: number;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
resolve: (result: AgentRunResult) => void;
|
||||
completed: Promise<AgentRunResult>;
|
||||
queue: CycleEvent[];
|
||||
emittedTokens: number;
|
||||
turnStarted: boolean;
|
||||
}
|
||||
|
||||
type CycleEvent =
|
||||
| { kind: "assistant_token"; text: string }
|
||||
| { kind: "reasoning_token"; text: string }
|
||||
| { kind: "tool_running"; callId: string; name: string; detail: ToolCallDetail }
|
||||
| { kind: "tool_completed"; callId: string; name: string; detail: ToolCallDetail }
|
||||
| { kind: "usage" };
|
||||
|
||||
interface LargeAgentStreamPayloadRequest {
|
||||
bytes: number;
|
||||
kind: "diff" | "file" | "image";
|
||||
@@ -175,47 +197,175 @@ function buildRepeatedPayload(bytes: number, prefix: string): string {
|
||||
return output.slice(0, bytes);
|
||||
}
|
||||
|
||||
function buildMarkdownDocument(iteration: number, prompt: AgentPromptInput): string {
|
||||
const promptPreview = promptToText(prompt).slice(0, 160) || "No prompt text supplied.";
|
||||
function tokenize(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const regex = /(\s*)(\S+)|(\s+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const [, leadingWs, word, lonelyWs] = match;
|
||||
if (lonelyWs !== undefined) {
|
||||
tokens.push(lonelyWs);
|
||||
continue;
|
||||
}
|
||||
const ws = leadingWs ?? "";
|
||||
const w = word ?? "";
|
||||
if (w.length <= 5) {
|
||||
tokens.push(ws + w);
|
||||
continue;
|
||||
}
|
||||
tokens.push(ws + w.slice(0, 4));
|
||||
for (let i = 4; i < w.length; i += 4) {
|
||||
tokens.push(w.slice(i, i + 4));
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function buildIntroParagraph(cycle: number): string {
|
||||
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.",
|
||||
`## Cycle ${cycle}`,
|
||||
"",
|
||||
"I'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of behavior is usually a thin layout effect over a ref, and the bugs tend to come from event ordering rather than the math itself, so the first useful step is to read the relevant files.",
|
||||
].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));
|
||||
function buildReasoningText(): string {
|
||||
return "Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
|
||||
}
|
||||
|
||||
function buildMidParagraph(): string {
|
||||
return [
|
||||
"Now I have a clearer picture. The auto-scroll uses a ref on the FlatList and tracks whether the user has scrolled away from the bottom by comparing the offset against the content size. There are a few subtle issues worth flagging before we change anything:",
|
||||
"",
|
||||
"- The threshold for 'near the bottom' is hardcoded at 80px, which feels too tight on dense content where headers and tool calls take up a lot of vertical space.",
|
||||
"- We rely on `onContentSizeChange` to detect new content, but that fires after layout, not as the streaming delta arrives, so we end up scrolling one frame late on fast streams.",
|
||||
"- The gesture handler does not pause scroll-to-bottom while the user is actively dragging, which means a drag in progress can be visually overridden mid-frame.",
|
||||
"- Coalescing happens upstream, so the FlatList sees fewer updates than the wire — but each batch can still cause a relayout.",
|
||||
"",
|
||||
"Let me make a small adjustment to the threshold and add a flag for active gestures, then run a quick command to confirm the order of events when streaming is fast.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildClosingParagraph(): string {
|
||||
return "The change should keep scroll-to-bottom working when the user is at the bottom while not yanking the viewport when they are reading earlier messages. The bash output confirms the `userIsAtBottom` flag flips correctly during a simulated streaming burst, and the gesture flag suppresses scroll while a drag is active. If you want, I can follow up with a regression test that drives the FlatList with synthetic deltas at high frequency to lock in the behavior. For now, I'll stop here so you can take a look.";
|
||||
}
|
||||
|
||||
function buildEditDiff(filePath: string): string {
|
||||
return [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
`--- a/${filePath}`,
|
||||
`+++ b/${filePath}`,
|
||||
"@@ -42,7 +42,9 @@",
|
||||
" const ref = useRef<FlatList>(null);",
|
||||
" const [userIsAtBottom, setUserIsAtBottom] = useState(true);",
|
||||
"- const NEAR_BOTTOM_PX = 80;",
|
||||
"+ const NEAR_BOTTOM_PX = 160;",
|
||||
"+ const isDraggingRef = useRef(false);",
|
||||
"",
|
||||
"- if (userIsAtBottom) ref.current?.scrollToEnd({ animated: true });",
|
||||
"+ if (userIsAtBottom && !isDraggingRef.current) {",
|
||||
"+ ref.current?.scrollToEnd({ animated: true });",
|
||||
"+ }",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildCycleQueue(turnId: string, cycle: number): CycleEvent[] {
|
||||
const queue: CycleEvent[] = [];
|
||||
|
||||
for (const tok of tokenize(buildIntroParagraph(cycle))) {
|
||||
queue.push({ kind: "assistant_token", text: tok });
|
||||
}
|
||||
return parts;
|
||||
|
||||
for (const tok of tokenize(buildReasoningText())) {
|
||||
queue.push({ kind: "reasoning_token", text: tok });
|
||||
}
|
||||
|
||||
const readDetail: ToolCallDetail = {
|
||||
type: "read",
|
||||
filePath: "packages/app/src/components/conversation-list.tsx",
|
||||
};
|
||||
const readId = `${turnId}:read:${cycle}`;
|
||||
queue.push({ kind: "tool_running", callId: readId, name: "read", detail: readDetail });
|
||||
queue.push({
|
||||
kind: "tool_completed",
|
||||
callId: readId,
|
||||
name: "read",
|
||||
detail: {
|
||||
...readDetail,
|
||||
content:
|
||||
"export function ConversationList() {\n const ref = useRef<FlatList>(null);\n // ...\n}",
|
||||
},
|
||||
});
|
||||
|
||||
const grepDetail: ToolCallDetail = {
|
||||
type: "search",
|
||||
query: "scrollToEnd",
|
||||
toolName: "grep",
|
||||
mode: "files_with_matches",
|
||||
};
|
||||
const grepId = `${turnId}:grep:${cycle}`;
|
||||
queue.push({ kind: "tool_running", callId: grepId, name: "grep", detail: grepDetail });
|
||||
queue.push({
|
||||
kind: "tool_completed",
|
||||
callId: grepId,
|
||||
name: "grep",
|
||||
detail: {
|
||||
...grepDetail,
|
||||
filePaths: [
|
||||
"packages/app/src/components/conversation-list.tsx",
|
||||
"packages/app/src/hooks/use-scroll-anchor.ts",
|
||||
],
|
||||
numFiles: 2,
|
||||
numMatches: 5,
|
||||
},
|
||||
});
|
||||
|
||||
for (const tok of tokenize(buildMidParagraph())) {
|
||||
queue.push({ kind: "assistant_token", text: tok });
|
||||
}
|
||||
|
||||
const editFile = "packages/app/src/hooks/use-scroll-anchor.ts";
|
||||
const editDetail: ToolCallDetail = {
|
||||
type: "edit",
|
||||
filePath: editFile,
|
||||
oldString: "const NEAR_BOTTOM_PX = 80;",
|
||||
newString: "const NEAR_BOTTOM_PX = 160;",
|
||||
unifiedDiff: buildEditDiff(editFile),
|
||||
};
|
||||
const editId = `${turnId}:edit:${cycle}`;
|
||||
queue.push({ kind: "tool_running", callId: editId, name: "edit", detail: editDetail });
|
||||
queue.push({ kind: "tool_completed", callId: editId, name: "edit", detail: editDetail });
|
||||
|
||||
const shellDetail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: "node scripts/simulate-stream-burst.mjs",
|
||||
cwd: "/tmp/paseo-mock-load",
|
||||
output:
|
||||
"[burst] tick 1 userIsAtBottom=true\n[burst] tick 2 userIsAtBottom=true\n[burst] drag-start isDragging=true\n[burst] tick 3 suppressed\n[burst] drag-end isDragging=false\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
const shellId = `${turnId}:bash:${cycle}`;
|
||||
queue.push({ kind: "tool_running", callId: shellId, name: "bash", detail: shellDetail });
|
||||
queue.push({ kind: "tool_completed", callId: shellId, name: "bash", detail: shellDetail });
|
||||
|
||||
for (const tok of tokenize(buildClosingParagraph())) {
|
||||
queue.push({ kind: "assistant_token", text: tok });
|
||||
}
|
||||
|
||||
queue.push({ kind: "usage" });
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
function createToolCall(input: {
|
||||
turnId: string;
|
||||
iteration: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
status: ToolCallTimelineItem["status"];
|
||||
detail: ToolCallTimelineItem["detail"];
|
||||
detail: ToolCallDetail;
|
||||
}): ToolCallTimelineItem {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `${input.turnId}:${input.name}:${input.iteration}`,
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
error: null,
|
||||
@@ -330,12 +480,15 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turnId,
|
||||
prompt,
|
||||
startedAt: Date.now(),
|
||||
iteration: 0,
|
||||
cycle: 0,
|
||||
durationMs: profile.durationMs,
|
||||
intervalMs: profile.intervalMs,
|
||||
timer: null,
|
||||
resolve,
|
||||
completed,
|
||||
queue: [],
|
||||
emittedTokens: 0,
|
||||
turnStarted: false,
|
||||
};
|
||||
this.activeTurn = turn;
|
||||
|
||||
@@ -608,8 +761,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
createToolCall({
|
||||
turnId: turn.turnId,
|
||||
iteration: 0,
|
||||
callId: `${turn.turnId}:edit:large`,
|
||||
name: "edit",
|
||||
status: "completed",
|
||||
detail: {
|
||||
@@ -623,8 +775,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
createToolCall({
|
||||
turnId: turn.turnId,
|
||||
iteration: 0,
|
||||
callId: `${turn.turnId}:read:large`,
|
||||
name: "read",
|
||||
status: "completed",
|
||||
detail: {
|
||||
@@ -669,7 +820,8 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.clearTurnTimer(turn);
|
||||
if (turn.iteration === 0) {
|
||||
if (!turn.turnStarted) {
|
||||
turn.turnStarted = true;
|
||||
this.emit({
|
||||
type: "turn_started",
|
||||
provider: this.provider,
|
||||
@@ -683,103 +835,71 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
turn.iteration += 1;
|
||||
this.emitIteration(turn);
|
||||
if (turn.queue.length === 0) {
|
||||
turn.cycle += 1;
|
||||
turn.queue = buildCycleQueue(turn.turnId, turn.cycle);
|
||||
}
|
||||
|
||||
const event = turn.queue.shift();
|
||||
if (event) {
|
||||
this.dispatchCycleEvent(turn, event);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
private dispatchCycleEvent(turn: ActiveTurn, event: CycleEvent): void {
|
||||
switch (event.kind) {
|
||||
case "assistant_token": {
|
||||
turn.emittedTokens += 1;
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: event.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "reasoning_token": {
|
||||
turn.emittedTokens += 1;
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "reasoning",
|
||||
text: event.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "tool_running":
|
||||
case "tool_completed": {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
createToolCall({
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
status: event.kind === "tool_running" ? "running" : "completed",
|
||||
detail: event.detail,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "usage": {
|
||||
this.emit({
|
||||
type: "usage_updated",
|
||||
provider: this.provider,
|
||||
turnId: turn.turnId,
|
||||
usage: {
|
||||
inputTokens: turn.cycle * 32,
|
||||
outputTokens: turn.emittedTokens,
|
||||
contextWindowUsedTokens: turn.emittedTokens * 2,
|
||||
contextWindowMaxTokens: 128_000,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: "## Synthetic load test complete\n\nThe mock provider finished its foreground turn.\n",
|
||||
text: "\n\n_(end of synthetic stream)_\n",
|
||||
});
|
||||
this.finishTurnWithText(turn, "Synthetic load test complete");
|
||||
}
|
||||
@@ -787,9 +907,9 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
private finishTurnWithText(turn: ActiveTurn, finalText: string): void {
|
||||
this.activeTurn = null;
|
||||
const usage = {
|
||||
inputTokens: turn.iteration * 32,
|
||||
outputTokens: turn.iteration * 128,
|
||||
contextWindowUsedTokens: turn.iteration * 160,
|
||||
inputTokens: turn.cycle * 32,
|
||||
outputTokens: turn.emittedTokens,
|
||||
contextWindowUsedTokens: turn.emittedTokens * 2,
|
||||
contextWindowMaxTokens: 128_000,
|
||||
};
|
||||
this.emit({
|
||||
|
||||
Reference in New Issue
Block a user