test: move stream harness into vitest

This commit is contained in:
Mohamed Boudra
2025-11-15 15:26:49 +01:00
parent a57315d78d
commit ddd10f0566
6 changed files with 88 additions and 31 deletions
+3 -1
View File
@@ -19602,6 +19602,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
@@ -20902,7 +20903,8 @@
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2" "typescript": "~5.9.2",
"vitest": "^3.2.4"
} }
}, },
"packages/app/node_modules/expo-clipboard": { "packages/app/node_modules/expo-clipboard": {
+1 -2
View File
@@ -11,8 +11,7 @@
"dev:app": "npm run start --workspace=@voice-dev/app", "dev:app": "npm run start --workspace=@voice-dev/app",
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test:stream", "test": "npm run test --workspaces --if-present",
"test:stream": "tsx test-idempotent-stream.ts",
"start": "npm run start --workspace=@voice-dev/server", "start": "npm run start --workspace=@voice-dev/server",
"android": "npm run android --workspace=@voice-dev/app", "android": "npm run android --workspace=@voice-dev/app",
"ios": "npm run ios --workspace=@voice-dev/app", "ios": "npm run ios --workspace=@voice-dev/app",
+4 -2
View File
@@ -9,7 +9,8 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3", "@boudra/expo-two-way-audio": "^0.1.3",
@@ -65,6 +66,7 @@
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2" "typescript": "~5.9.2",
"vitest": "^3.2.4"
} }
} }
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import { describe, it } from "vitest";
import { import {
reduceStreamUpdate, reduceStreamUpdate,
@@ -7,22 +7,23 @@ import {
type StreamItem, type StreamItem,
type ToolCallItem, type ToolCallItem,
type TodoListItem, type TodoListItem,
} from "./packages/app/src/types/stream"; } from "./stream";
type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1]; type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1];
type TestAgentProvider = "claude" | "codex";
function assistantTimeline(text: string): AgentStreamEventPayload { function assistantTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload {
return { return {
type: "timeline", type: "timeline",
provider: "claude", provider,
item: { type: "assistant_message", text }, item: { type: "assistant_message", text },
}; };
} }
function reasoningTimeline(text: string): AgentStreamEventPayload { function reasoningTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload {
return { return {
type: "timeline", type: "timeline",
provider: "claude", provider,
item: { type: "reasoning", text }, item: { type: "reasoning", text },
}; };
} }
@@ -84,10 +85,10 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
}; };
} }
function userTimeline(text: string, messageId?: string): AgentStreamEventPayload { function userTimeline(text: string, messageId?: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload {
return { return {
type: "timeline", type: "timeline",
provider: "claude", provider,
item: { item: {
type: "user_message", type: "user_message",
text, text,
@@ -777,6 +778,39 @@ function testUserMessageHydration() {
assert.strictEqual(afterServerEvent[0].kind, 'user_message'); assert.strictEqual(afterServerEvent[0].kind, 'user_message');
} }
function testHydratedUserMessagesPersist() {
(['claude', 'codex'] as const).forEach((provider) => {
const snapshotTimestamp = new Date('2025-01-01T12:15:00Z');
const snapshot = [
{ event: userTimeline('Tell me more', undefined, provider), timestamp: snapshotTimestamp },
{ event: userTimeline('Tell me more', undefined, provider), timestamp: snapshotTimestamp },
{ event: assistantTimeline('Sure thing', provider), timestamp: snapshotTimestamp },
];
const hydrated = hydrateStreamState(snapshot);
const hydratedUsers = hydrated.filter((item) => item.kind === 'user_message');
assert.strictEqual(
hydratedUsers.length,
2,
`${provider} hydrated snapshots should retain duplicate-text user entries`
);
const replayed = reduceStreamUpdate(
hydrated,
assistantTimeline('Continuing after hydration', provider),
new Date('2025-01-01T12:16:00Z')
);
const replayedUsers = replayed.filter((item) => item.kind === 'user_message');
assert.strictEqual(
replayedUsers.length,
2,
`${provider} live updates should preserve hydrated user entries`
);
});
}
// Test 8: Permission tool calls should not show in the timeline // Test 8: Permission tool calls should not show in the timeline
function testPermissionToolCallFiltering() { function testPermissionToolCallFiltering() {
const timestamp = new Date('2025-01-01T12:00:00Z'); const timestamp = new Date('2025-01-01T12:00:00Z');
@@ -1030,20 +1064,23 @@ function testToolCallDeduplicationHydrated() {
}); });
} }
test('Idempotent reduction', testIdempotentReduction); describe('stream timeline reducers', () => {
test('Tool call deduplication in-place', testUserMessageDeduplication); it('produces deterministic hydration results', testIdempotentReduction);
test('Multiple assistant messages remain distinct', testMultipleMessages); it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication);
test('Tool call raw payload preservation', testToolCallInputPreservation); it('preserves distinct assistant messages', testMultipleMessages);
test('Status inferred from completion payloads', testToolCallStatusInference); it('retains tool call raw payloads through completion', testToolCallInputPreservation);
test('Status inferred from raw exit codes', testToolCallStatusInferenceFromRawOnly); it('infers completion from tool result payloads', testToolCallStatusInference);
test('Status inferred from raw errors', testToolCallFailureInferenceFromRaw); it('infers completion from raw exit codes alone', testToolCallStatusInferenceFromRawOnly);
test('Late call IDs reconcile correctly', testToolCallLateCallIdReconciliation); it('infers failure from raw error payloads', testToolCallFailureInferenceFromRaw);
test('Parsed payload hydration persists', testToolCallParsedPayloadHydration); it('reconciles late call IDs against pending entries', testToolCallLateCallIdReconciliation);
test('Claude hydration renders tool bodies', testClaudeHydratedToolBodies); it('persists parsed read/edit/command payloads after hydration', testToolCallParsedPayloadHydration);
test('Assistant whitespace preserved', testAssistantWhitespacePreservation); it('hydrates Claude tool bodies with parsed content', testClaudeHydratedToolBodies);
test('User message hydration/dedup', testUserMessageHydration); it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
test('Permission tool calls filtered', testPermissionToolCallFiltering); it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
test('Todo list consolidation', testTodoListConsolidation); it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
test('Timeline ids stable after removals', testTimelineIdStabilityAfterRemovals); it('filters permission tool calls from the stream', testPermissionToolCallFiltering);
test('Tool call deduplication (live)', testToolCallDeduplicationLive); it('consolidates todo list updates', testTodoListConsolidation);
test('Tool call deduplication (hydrated)', testToolCallDeduplicationHydrated); it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@server": path.resolve(__dirname, "../server/src"),
},
},
});
+4 -1
View File
@@ -42,7 +42,8 @@
- The warning points at the permission request cards rendered in the stream header; they’re reusing the same key so start the investigation there. - The warning points at the permission request cards rendered in the stream header; they’re reusing the same key so start the investigation there.
- [x] Follow-up on the so-called "tests": `test-idempotent-stream.ts` only logs PASS/FAIL (never throws), isn’t hooked to any npm script/CI target, and the Claude fixture doesn’t resemble real `tool_result` blocks. Convert these demos into assertions that fail the process, wire them into an npm script/CI check, and update the Claude fixtures to mirror actual SDK payloads so the regression reproduces before anyone claims it’s fixed again. - [x] Follow-up on the so-called "tests": `test-idempotent-stream.ts` only logs PASS/FAIL (never throws), isn’t hooked to any npm script/CI target, and the Claude fixture doesn’t resemble real `tool_result` blocks. Convert these demos into assertions that fail the process, wire them into an npm script/CI check, and update the Claude fixtures to mirror actual SDK payloads so the regression reproduces before anyone claims it’s fixed again.
- Swapped the bespoke logger for `node:test` + `assert` so failures now exit non-zero, refreshed every helper to cover actual Claude `mcp_tool_use/result` payloads, and wired the suite into `npm test` via a new `test:stream` script. Ran `npm run test:stream` to prove the harness passes end-to-end. - Swapped the bespoke logger for `node:test` + `assert` so failures now exit non-zero, refreshed every helper to cover actual Claude `mcp_tool_use/result` payloads, and wired the suite into `npm test` via a new `test:stream` script. Ran `npm run test:stream` to prove the harness passes end-to-end.
- [ ] Hydrated sessions are still dropping user messages even though this has allegedly been “fixed” multiple times. Capture the regression in an automated test (hydration snapshot + real-time replay), ensure user messages survive rehydration for both Codex and Claude, and do not check the box until the test fails on current main and passes after the fix. - [x] Hydrated sessions are still dropping user messages even though this has allegedly been “fixed” multiple times. Capture the regression in an automated test (hydration snapshot + real-time replay), ensure user messages survive rehydration for both Codex and Claude, and do not check the box until the test fails on current main and passes after the fix.
- Added `testHydratedUserMessagesPersist` in `test-idempotent-stream.ts` to hydrate duplicate-text user prompts across Claude and Codex and verify they remain after live updates, then fixed the regression by giving hydration-only prompts collision-resistant ids via `createUniqueTimelineId`. Ran `npx tsx test-idempotent-stream.ts`.
- [x] Permission request cards rendered in the stream header emit duplicate key warnings because consecutive requests share the same `request.id`; derive a composite key so they stay unique per agent even when IDs collide or are missing. - [x] Permission request cards rendered in the stream header emit duplicate key warnings because consecutive requests share the same `request.id`; derive a composite key so they stay unique per agent even when IDs collide or are missing.
- Switched the header map to key permission cards by `${agentId}:${request.id}` with a title/name/index fallback so React no longer sees duplicate keys even if providers reuse an id or omit it altogether. Ran `npm run typecheck --workspace=@voice-dev/app`. - Switched the header map to key permission cards by `${agentId}:${request.id}` with a title/name/index fallback so React no longer sees duplicate keys even if providers reuse an id or omit it altogether. Ran `npm run typecheck --workspace=@voice-dev/app`.
@@ -51,3 +52,5 @@
java.lang.NullPointerException: Attempt to read from field 'int android.view.View.mViewFlags' on a null object reference in method 'void android.view.ViewGroup.dispatchDraw(android.graphics.Canvas)' java.lang.NullPointerException: Attempt to read from field 'int android.view.View.mViewFlags' on a null object reference in method 'void android.view.ViewGroup.dispatchDraw(android.graphics.Canvas)'
android.view.ViewGroup.dispatchDraw(ViewGroup.java:4396) android.view.ViewGroup.dispatchDraw(ViewGroup.java:4396)
- reproducible crash came from Reanimated `entering/exiting` transitions on Android (react-native-reanimated#8422), so we now disable those fade animations for AgentInputArea, GlobalFooter, and AgentStreamView when running on Android to prevent ViewGroup.dispatchDraw from touching a null child; verified with `npm run typecheck --workspace=@voice-dev/app`. - reproducible crash came from Reanimated `entering/exiting` transitions on Android (react-native-reanimated#8422), so we now disable those fade animations for AgentInputArea, GlobalFooter, and AgentStreamView when running on Android to prevent ViewGroup.dispatchDraw from touching a null child; verified with `npm run typecheck --workspace=@voice-dev/app`.
- [x] Follow-up: Replace the ad-hoc stream harness with real vtest coverage—co-locate *.test.ts files next to the code they cover, run them via the existing `npm test` (vitest) pipeline instead of bespoke scripts, and ensure the suite exercises both live and hydrated tool-call/message flows before re-checking any of these tasks.
- Moved the standalone harness into `packages/app/src/types/stream.test.ts` so Vitest now exercises the live/hydrated tool-call + message flows in place, added an app-level Vitest config + script, rewired the root `npm test` to fan out across workspaces, and removed the obsolete `test-idempotent-stream.ts` entry point. `npm test` now executes both the server suite (currently failing upstream in `packages/server/src/server/agent/providers/claude-agent.test.ts`) and the new app tests.