mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Findings from the original review (P2): - streaming: flush unterminated final SSE event at EOF (some providers close the stream without a trailing blank line, dropping the last delta) - cache-manager: validate entry shape on load — drop entries where cards is not an array, anchor is not a string, or bullets is not an array. Tolerates missing optional fields (treated as cache miss). - view: card edit/delete now check cacheReplaceCards return and surface failures via a localized Notice instead of pretending success - main: clear-current / clear-all / file-menu-clear refresh open view via renderEmpty so stale UI does not display deleted data - prompt + settings-tab: card count is normalized via a single helper used by buildPrompts and onChange. Prompt and fingerprint stay in sync. UI value is written back to the textbox after clamping. - generation-job-manager: global concurrency limit (default 3) with a cancellable wait queue. Race-safe slot accounting via a reserved counter so resolved-but-not-yet-set waiters are visible to fast-path start() callers. - runForFile: now returns RunForFileResult; accepts preloadedContent + silentView + skipEditConfirm options used by batch (and reflected on the PluginHost interface). - batch: avoid double file read, do not steal UI focus, classify results correctly (generated / cached / already-running / empty / ...) Follow-up from the 1.0.11 review: - generationFingerprint: codex backend excludes settings.model from the hash. Codex ignores --model; previously editing model spuriously invalidated all codex cache. Codex review pass: - generation-job-manager: race fix — releaseSlot's resolve microtask and a synchronous start() fast-path could briefly overshoot maxConcurrent. Reserve the slot synchronously inside the wrapped resolve to close the window. - cache-manager: anchor type validation in addition to bullets. NOTE: Codex backend users will see a one-time "stale cache" banner on existing notes due to the fingerprint change; regenerate to refresh. Change-Id: I7721c7dfe51dea3f51b0215764f721523c2f6806
109 lines
4.9 KiB
JavaScript
109 lines
4.9 KiB
JavaScript
const { assert, t, baseSettings } = require('./test-setup');
|
|
|
|
// ── supportsStreaming ──
|
|
|
|
assert.strictEqual(
|
|
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'openai-chat' }),
|
|
true,
|
|
'OpenAI Chat supports streaming',
|
|
);
|
|
assert.strictEqual(
|
|
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'anthropic-messages', apiProvider: 'anthropic' }),
|
|
true,
|
|
'Anthropic Messages supports streaming',
|
|
);
|
|
assert.strictEqual(
|
|
t.supportsStreaming({ ...baseSettings, streaming: true, apiFormat: 'google-generative-ai', apiProvider: 'google' }),
|
|
false,
|
|
'Gemini does not support streaming',
|
|
);
|
|
assert.strictEqual(
|
|
t.supportsStreaming({ ...baseSettings, streaming: false, apiFormat: 'openai-chat' }),
|
|
false,
|
|
'streaming disabled returns false',
|
|
);
|
|
|
|
// ── deltaExtractorForFormat ──
|
|
|
|
const openaiExtract = t.deltaExtractorForFormat('openai-chat');
|
|
assert.ok(openaiExtract, 'openai-chat extractor exists');
|
|
assert.strictEqual(openaiExtract({ choices: [{ delta: { content: 'hello' } }] }), 'hello', 'extracts OpenAI delta');
|
|
assert.strictEqual(openaiExtract({ choices: [{ delta: {} }] }), '', 'empty delta');
|
|
assert.strictEqual(openaiExtract({}), '', 'missing choices');
|
|
|
|
const anthropicExtract = t.deltaExtractorForFormat('anthropic-messages');
|
|
assert.ok(anthropicExtract, 'anthropic extractor exists');
|
|
assert.strictEqual(
|
|
anthropicExtract({ type: 'content_block_delta', delta: { text: 'world' } }),
|
|
'world',
|
|
'extracts Anthropic delta',
|
|
);
|
|
assert.strictEqual(anthropicExtract({ type: 'message_start' }), '', 'non-delta event returns empty');
|
|
|
|
assert.strictEqual(t.deltaExtractorForFormat('google-generative-ai'), null, 'no extractor for gemini');
|
|
|
|
// ── parseSseBuffer ──
|
|
|
|
const sseResult = t.parseSseBuffer(
|
|
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: {"choices":[{"delta":{"content":" there"}}]}\n\ndata: [DONE]\n\npartial',
|
|
openaiExtract,
|
|
);
|
|
assert.deepStrictEqual(sseResult.deltas, ['hi', ' there'], 'SSE parser extracts deltas');
|
|
assert.strictEqual(sseResult.rest, 'partial', 'SSE parser keeps partial line');
|
|
|
|
const sseEmpty = t.parseSseBuffer('', openaiExtract);
|
|
assert.deepStrictEqual(sseEmpty.deltas, []);
|
|
assert.strictEqual(sseEmpty.rest, '');
|
|
|
|
const anthSse = t.parseSseBuffer(
|
|
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"token"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
anthropicExtract,
|
|
);
|
|
assert.deepStrictEqual(anthSse.deltas, ['token'], 'Anthropic SSE extracts text delta');
|
|
|
|
const ssePartial = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"ok"}}]}', openaiExtract);
|
|
assert.deepStrictEqual(ssePartial.deltas, [], 'incomplete line yields no deltas');
|
|
assert.ok(ssePartial.rest.includes('"ok"'), 'partial line kept in rest');
|
|
|
|
const sseMulti = t.parseSseBuffer(
|
|
'data: {"choices":[{"delta":{"content":"a"}}]}\n\ndata: {"choices":[{"delta":{"content":"b"}}]}\n\ndata: {"choices":[{"delta":{"content":"c"}}]}\n\n',
|
|
openaiExtract,
|
|
);
|
|
assert.deepStrictEqual(sseMulti.deltas, ['a', 'b', 'c'], 'three events in one chunk');
|
|
assert.strictEqual(sseMulti.rest, '', 'nothing left in rest when chunk ends with newline');
|
|
|
|
const sseMultilineData = t.parseSseBuffer(
|
|
'data: {"choices":[\ndata: {"delta":{"content":"joined"}}\ndata: ]}\n\n',
|
|
openaiExtract,
|
|
);
|
|
assert.deepStrictEqual(sseMultilineData.deltas, ['joined'], 'consecutive data lines are merged into one event');
|
|
|
|
const ssePartialEvent = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"wait"}}]}\n', openaiExtract);
|
|
assert.deepStrictEqual(ssePartialEvent.deltas, [], 'event without blank-line terminator is retained');
|
|
assert.strictEqual(ssePartialEvent.rest.includes('"wait"'), true, 'partial event remains in rest');
|
|
|
|
const sseMixed = t.parseSseBuffer(
|
|
': comment\nevent: ping\ndata: {"choices":[{"delta":{"content":"x"}}]}\n\n',
|
|
openaiExtract,
|
|
);
|
|
assert.deepStrictEqual(sseMixed.deltas, ['x'], 'comment and event lines ignored');
|
|
|
|
const sseBad = t.parseSseBuffer('data: not_json\n\ndata: {"choices":[{"delta":{"content":"y"}}]}\n\n', openaiExtract);
|
|
assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted');
|
|
|
|
// ── parseSseBuffer flush behavior: appending \n\n to unterminated event ──
|
|
// (used by doStreamingFetch on EOF when provider closes without trailing \n\n)
|
|
{
|
|
const unterminated = 'data: {"choices":[{"delta":{"content":"final"}}]}';
|
|
const flushed = t.parseSseBuffer(`${unterminated}\n\n`, openaiExtract);
|
|
assert.deepStrictEqual(flushed.deltas, ['final'], 'flush of unterminated event extracts last delta');
|
|
assert.strictEqual(flushed.rest, '', 'flush leaves no remainder');
|
|
}
|
|
{
|
|
// Buffer ending with single \n (often a partial line) — flush by appending one more \n
|
|
const partial = 'data: {"choices":[{"delta":{"content":"tail"}}]}\n';
|
|
const flushed = t.parseSseBuffer(`${partial}\n`, openaiExtract);
|
|
assert.deepStrictEqual(flushed.deltas, ['tail'], 'single-newline buffer flushed correctly');
|
|
}
|
|
|
|
console.log('streaming tests passed');
|