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

4
package-lock.json generated
View File

@@ -19602,6 +19602,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -20902,7 +20903,8 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.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": {

View File

@@ -11,8 +11,7 @@
"dev:app": "npm run start --workspace=@voice-dev/app",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test:stream",
"test:stream": "tsx test-idempotent-stream.ts",
"test": "npm run test --workspaces --if-present",
"start": "npm run start --workspace=@voice-dev/server",
"android": "npm run android --workspace=@voice-dev/app",
"ios": "npm run ios --workspace=@voice-dev/app",

View File

@@ -9,7 +9,8 @@
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
@@ -65,6 +66,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
"typescript": "~5.9.2",
"vitest": "^3.2.4"
}
}

View File

@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describe, it } from "vitest";
import {
reduceStreamUpdate,
@@ -7,22 +7,23 @@ import {
type StreamItem,
type ToolCallItem,
type TodoListItem,
} from "./packages/app/src/types/stream";
} from "./stream";
type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1];
type TestAgentProvider = "claude" | "codex";
function assistantTimeline(text: string): AgentStreamEventPayload {
function assistantTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
provider,
item: { type: "assistant_message", text },
};
}
function reasoningTimeline(text: string): AgentStreamEventPayload {
function reasoningTimeline(text: string, provider: TestAgentProvider = "claude"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
provider,
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 {
type: "timeline",
provider: "claude",
provider,
item: {
type: "user_message",
text,
@@ -777,6 +778,39 @@ function testUserMessageHydration() {
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
function testPermissionToolCallFiltering() {
const timestamp = new Date('2025-01-01T12:00:00Z');
@@ -1030,20 +1064,23 @@ function testToolCallDeduplicationHydrated() {
});
}
test('Idempotent reduction', testIdempotentReduction);
test('Tool call deduplication in-place', testUserMessageDeduplication);
test('Multiple assistant messages remain distinct', testMultipleMessages);
test('Tool call raw payload preservation', testToolCallInputPreservation);
test('Status inferred from completion payloads', testToolCallStatusInference);
test('Status inferred from raw exit codes', testToolCallStatusInferenceFromRawOnly);
test('Status inferred from raw errors', testToolCallFailureInferenceFromRaw);
test('Late call IDs reconcile correctly', testToolCallLateCallIdReconciliation);
test('Parsed payload hydration persists', testToolCallParsedPayloadHydration);
test('Claude hydration renders tool bodies', testClaudeHydratedToolBodies);
test('Assistant whitespace preserved', testAssistantWhitespacePreservation);
test('User message hydration/dedup', testUserMessageHydration);
test('Permission tool calls filtered', testPermissionToolCallFiltering);
test('Todo list consolidation', testTodoListConsolidation);
test('Timeline ids stable after removals', testTimelineIdStabilityAfterRemovals);
test('Tool call deduplication (live)', testToolCallDeduplicationLive);
test('Tool call deduplication (hydrated)', testToolCallDeduplicationHydrated);
describe('stream timeline reducers', () => {
it('produces deterministic hydration results', testIdempotentReduction);
it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication);
it('preserves distinct assistant messages', testMultipleMessages);
it('retains tool call raw payloads through completion', testToolCallInputPreservation);
it('infers completion from tool result payloads', testToolCallStatusInference);
it('infers completion from raw exit codes alone', testToolCallStatusInferenceFromRawOnly);
it('infers failure from raw error payloads', testToolCallFailureInferenceFromRaw);
it('reconciles late call IDs against pending entries', testToolCallLateCallIdReconciliation);
it('persists parsed read/edit/command payloads after hydration', testToolCallParsedPayloadHydration);
it('hydrates Claude tool bodies with parsed content', testClaudeHydratedToolBodies);
it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
it('filters permission tool calls from the stream', testPermissionToolCallFiltering);
it('consolidates todo list updates', testTodoListConsolidation);
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
});

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"),
},
},
});

View File

@@ -42,7 +42,8 @@
- The warning points at the permission request cards rendered in the stream header; theyre 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), isnt hooked to any npm script/CI target, and the Claude fixture doesnt 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 its 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.
- [ ] 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.
- 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)'
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`.
- [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.