test: move stream harness into vitest
This commit is contained in:
Generated
+3
-1
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user