test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -3,18 +3,45 @@ import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(__private__.buildInitialTimelineRequest(undefined)).toEqual({
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor when present", () => {
|
||||
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
},
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor when local tail exists", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
},
|
||||
hasLocalTail: true,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
@@ -23,4 +50,18 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports unbounded tail bootstrap policy", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 0,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type {
|
||||
DaemonClient,
|
||||
@@ -13,20 +14,28 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||
|
||||
type TimelineCursorState = {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
type BuildInitialTimelineRequestInput = {
|
||||
cursor: TimelineCursorState | undefined;
|
||||
hasLocalTail: boolean;
|
||||
initialTimelineLimit: number;
|
||||
};
|
||||
|
||||
function buildInitialTimelineRequest(
|
||||
cursor: TimelineCursorState | undefined
|
||||
input: BuildInitialTimelineRequestInput
|
||||
): FetchAgentTimelineOptions {
|
||||
if (!cursor) {
|
||||
const { cursor, hasLocalTail, initialTimelineLimit } = input;
|
||||
if (!cursor || !hasLocalTail) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
@@ -40,8 +49,15 @@ function buildInitialTimelineRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInitialTimelineLimit(): number {
|
||||
return Platform.OS === "web"
|
||||
? UNBOUNDED_TIMELINE_LIMIT
|
||||
: NATIVE_INITIAL_TIMELINE_LIMIT;
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
buildInitialTimelineRequest,
|
||||
resolveInitialTimelineLimit,
|
||||
};
|
||||
|
||||
export function useAgentInitialization({
|
||||
@@ -76,7 +92,13 @@ export function useAgentInitialization({
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const timelineRequest = buildInitialTimelineRequest(cursor);
|
||||
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const timelineRequest = buildInitialTimelineRequest({
|
||||
cursor,
|
||||
hasLocalTail,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
const initRequestDirection =
|
||||
timelineRequest.direction === "after" ? "after" : "tail";
|
||||
|
||||
@@ -128,9 +150,10 @@ export function useAgentInitialization({
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -57,6 +57,92 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
return out;
|
||||
};
|
||||
|
||||
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i]! / 32768;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
type Pcm16Wav = {
|
||||
sampleRate: number;
|
||||
samples: Int16Array;
|
||||
};
|
||||
|
||||
const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => {
|
||||
if (buffer.byteLength < 44) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const readAscii = (offset: number, length: number): string => {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
out += String.fromCharCode(view.getUint8(offset + i));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let channels = 0;
|
||||
let sampleRate = 0;
|
||||
let bitsPerSample = 0;
|
||||
let dataOffset = 0;
|
||||
let dataSize = 0;
|
||||
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const chunkId = readAscii(offset, 4);
|
||||
const chunkSize = view.getUint32(offset + 4, true);
|
||||
const chunkDataOffset = offset + 8;
|
||||
if (chunkDataOffset + chunkSize > buffer.byteLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunkId === "fmt " && chunkSize >= 16) {
|
||||
const audioFormat = view.getUint16(chunkDataOffset, true);
|
||||
channels = view.getUint16(chunkDataOffset + 2, true);
|
||||
sampleRate = view.getUint32(chunkDataOffset + 4, true);
|
||||
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
|
||||
if (audioFormat !== 1) {
|
||||
return null;
|
||||
}
|
||||
} else if (chunkId === "data") {
|
||||
dataOffset = chunkDataOffset;
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
|
||||
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleCount = Math.floor(dataSize / 2);
|
||||
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
|
||||
|
||||
if (channels === 1) {
|
||||
return { sampleRate, samples: new Int16Array(interleaved) };
|
||||
}
|
||||
|
||||
const frameCount = Math.floor(interleaved.length / channels);
|
||||
const mono = new Int16Array(frameCount);
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
let sum = 0;
|
||||
for (let ch = 0; ch < channels; ch += 1) {
|
||||
sum += interleaved[frame * channels + ch] ?? 0;
|
||||
}
|
||||
mono[frame] = Math.round(sum / channels);
|
||||
}
|
||||
return { sampleRate, samples: mono };
|
||||
};
|
||||
|
||||
const int16ToBase64 = (pcm: Int16Array): string => {
|
||||
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
let binary = "";
|
||||
@@ -359,15 +445,22 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "recorder" && recorder.stoppedPromise && context) {
|
||||
if (mode === "recorder" && recorder.stoppedPromise) {
|
||||
try {
|
||||
const blob = await recorder.stoppedPromise;
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > 0) {
|
||||
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||
const floatPcm = decoded.getChannelData(0);
|
||||
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
const parsedWav = parsePcm16Wav(arrayBuffer);
|
||||
if (parsedWav) {
|
||||
const floatPcm = int16ToFloat32(parsedWav.samples);
|
||||
const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
} else if (context) {
|
||||
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||
const floatPcm = decoded.getChannelData(0);
|
||||
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||
emitPcmSegments(pcm16);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
@@ -32,6 +32,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const latestPartialTranscriptRef = useRef("");
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
useEffect(() => {
|
||||
@@ -130,6 +131,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const clearStreamingState = useCallback(() => {
|
||||
senderRef.current?.clearAll();
|
||||
latestPartialTranscriptRef.current = "";
|
||||
setPartialTranscript("");
|
||||
}, []);
|
||||
|
||||
@@ -181,6 +183,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
return;
|
||||
}
|
||||
const next = message.payload.text ?? "";
|
||||
latestPartialTranscriptRef.current = next;
|
||||
setPartialTranscript(next);
|
||||
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
|
||||
});
|
||||
@@ -203,13 +206,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
(text: string, requestId: string) => {
|
||||
setIsProcessing(false);
|
||||
isProcessingRef.current = false;
|
||||
setPartialTranscript("");
|
||||
setDuration(0);
|
||||
setStatus("idle");
|
||||
|
||||
const transcriptText = (
|
||||
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
|
||||
);
|
||||
clearStreamingState();
|
||||
|
||||
const transcriptText = text.trim();
|
||||
if (!transcriptText) {
|
||||
return;
|
||||
}
|
||||
@@ -261,14 +265,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
try {
|
||||
await audio.start();
|
||||
if (client?.isConnected) {
|
||||
await startNewStream("start");
|
||||
}
|
||||
isRecordingRef.current = true;
|
||||
setIsRecording(true);
|
||||
if (enableDuration) {
|
||||
startDurationTracking();
|
||||
}
|
||||
if (client?.isConnected) {
|
||||
await startNewStream("start");
|
||||
}
|
||||
} catch (err) {
|
||||
await audio.stop().catch(() => undefined);
|
||||
stopDurationTracking();
|
||||
|
||||
Reference in New Issue
Block a user