fix: carry reply tags across streamed chunks
This commit is contained in:
37
src/auto-reply/reply/streaming-directives.test.ts
Normal file
37
src/auto-reply/reply/streaming-directives.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||
|
||||
describe("createStreamingDirectiveAccumulator", () => {
|
||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hello");
|
||||
expect(result?.text).toBe("Hello");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("handles reply tags split across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("current]] Yo");
|
||||
expect(result?.text).toBe("Yo");
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
});
|
||||
124
src/auto-reply/reply/streaming-directives.ts
Normal file
124
src/auto-reply/reply/streaming-directives.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { splitMediaFromOutput } from "../../media/parse.js";
|
||||
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
|
||||
|
||||
type PendingReplyState = {
|
||||
explicitId?: string;
|
||||
sawCurrent: boolean;
|
||||
hasTag: boolean;
|
||||
};
|
||||
|
||||
type ParsedChunk = ReplyDirectiveParseResult & {
|
||||
replyToExplicitId?: string;
|
||||
};
|
||||
|
||||
type ConsumeOptions = {
|
||||
final?: boolean;
|
||||
silentToken?: string;
|
||||
};
|
||||
|
||||
const splitTrailingDirective = (text: string): { text: string; tail: string } => {
|
||||
const openIndex = text.lastIndexOf("[[");
|
||||
if (openIndex < 0) return { text, tail: "" };
|
||||
const closeIndex = text.indexOf("]]", openIndex + 2);
|
||||
if (closeIndex >= 0) return { text, tail: "" };
|
||||
return {
|
||||
text: text.slice(0, openIndex),
|
||||
tail: text.slice(openIndex),
|
||||
};
|
||||
};
|
||||
|
||||
const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChunk => {
|
||||
const split = splitMediaFromOutput(raw);
|
||||
let text = split.text ?? "";
|
||||
|
||||
const replyParsed = parseInlineDirectives(text, {
|
||||
stripAudioTag: false,
|
||||
stripReplyTags: true,
|
||||
});
|
||||
|
||||
if (replyParsed.hasReplyTag) {
|
||||
text = replyParsed.text;
|
||||
}
|
||||
|
||||
const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
const isSilent = isSilentReplyText(text, silentToken);
|
||||
if (isSilent) {
|
||||
text = "";
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
mediaUrls: split.mediaUrls,
|
||||
mediaUrl: split.mediaUrl,
|
||||
replyToId: replyParsed.replyToId,
|
||||
replyToExplicitId: replyParsed.replyToExplicitId,
|
||||
replyToCurrent: replyParsed.replyToCurrent,
|
||||
replyToTag: replyParsed.hasReplyTag,
|
||||
audioAsVoice: split.audioAsVoice,
|
||||
isSilent,
|
||||
};
|
||||
};
|
||||
|
||||
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
|
||||
Boolean(parsed.text) ||
|
||||
Boolean(parsed.mediaUrl) ||
|
||||
(parsed.mediaUrls?.length ?? 0) > 0 ||
|
||||
Boolean(parsed.audioAsVoice);
|
||||
|
||||
export function createStreamingDirectiveAccumulator() {
|
||||
let pendingTail = "";
|
||||
let pendingReply: PendingReplyState = { sawCurrent: false, hasTag: false };
|
||||
|
||||
const reset = () => {
|
||||
pendingTail = "";
|
||||
pendingReply = { sawCurrent: false, hasTag: false };
|
||||
};
|
||||
|
||||
const consume = (raw: string, options: ConsumeOptions = {}): ReplyDirectiveParseResult | null => {
|
||||
let combined = `${pendingTail}${raw ?? ""}`;
|
||||
pendingTail = "";
|
||||
|
||||
if (!options.final) {
|
||||
const split = splitTrailingDirective(combined);
|
||||
combined = split.text;
|
||||
pendingTail = split.tail;
|
||||
}
|
||||
|
||||
if (!combined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseChunk(combined, { silentToken: options.silentToken });
|
||||
const hasTag = pendingReply.hasTag || parsed.replyToTag;
|
||||
const sawCurrent = pendingReply.sawCurrent || parsed.replyToCurrent;
|
||||
const explicitId = parsed.replyToExplicitId ?? pendingReply.explicitId;
|
||||
|
||||
const combinedResult: ReplyDirectiveParseResult = {
|
||||
...parsed,
|
||||
replyToId: explicitId,
|
||||
replyToCurrent: sawCurrent,
|
||||
replyToTag: hasTag,
|
||||
};
|
||||
|
||||
if (!hasRenderableContent(combinedResult)) {
|
||||
if (hasTag) {
|
||||
pendingReply = {
|
||||
explicitId,
|
||||
sawCurrent,
|
||||
hasTag,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingReply = { sawCurrent: false, hasTag: false };
|
||||
return combinedResult;
|
||||
};
|
||||
|
||||
return {
|
||||
consume,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user