Files
obsidian-penny/test/integration.test.ts
james.maesandClaude Opus 4.6 4bcf751887 feat: real streaming, abortable cancellation, progress modal redesign
Three coupled fixes to the processing UX:

Streaming (providers/*, node-stream.ts):
Native fetch() is blocked by Obsidian's Electron CSP, so every provider
was silently falling back to non-streaming requestUrl. Added a Node https
client (bypasses CSP) and switched all four providers to it. onToken
callbacks now fire per chunk as the model streams.

Cancellation (pipeline.ts, service.ts, commands.ts):
Added AbortController per annotation and plumbed signal through the
provider interface. Cancel now actually tears down in-flight requests
via req.destroy() instead of waiting minutes for the current call to
finish.

Progress modal (progress-modal-state.ts, progress-modal.ts):
Split logic from DOM into a pure ProgressModalStateMachine so the
"Cancelling..." sticky-header and Close-re-triggers-Cancel bugs can't
recur -- Cancel and Close are now structurally distinct buttons,
recreated per render. Added a rotating "while we wait together..."
quote panel (quotes.ts) and a placeholder line in the stream preview
while waiting for the first token. Stream preview is the only element
that scrolls internally; outer log fits its content.

Tests: 451 passing (22 files). New coverage for state machine
transitions, cancellation propagation, and the Node stream client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:28:44 -05:00

725 lines
22 KiB
TypeScript

/**
* PENNY - Integration Test
*
* Tests the core processing pipeline (runPipeline) end-to-end
* with a mock LLM provider. Verifies:
* - Annotations are parsed and processed
* - Output has REVISED markers and annotations removed
* - Version number increments correctly
* - State tracks processed annotation hashes
* - Review note contains correct data
* - Idempotency: second run on same content produces no changes
*/
import { describe, it, expect } from "vitest";
import { runPipeline } from "../src/pipeline";
import type { PipelineInput, PipelineResult } from "../src/pipeline";
import type { PennySettings } from "../src/types";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { ContextFiles } from "../src/context";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const TEST_CHAPTER = `---
type: chapter
book: 1
chapter: 5
title: The Discovery
pov: protagonist
status: draft
wordcount: 100
focus: protagonist + tim
thread: mystery
act: 2
---
# Book 1, Chapter 5
## Scene Summary
> She investigates the anomaly with Tim.
---
<!-- Prose begins below -->
The lab was quiet at 3am. She stared at the screen.
%% REWRITE: More sensory detail - sounds, smells, the hum of equipment %%
"I don't understand what I'm seeing," she said to Rabbit.
%% EXPAND: Add more dialogue here. Show her working through the problem out loud. %%
The data made no sense.
%% NOTE: Come back to this scene after writing chapter 6. %%
%% RESEARCH: Is the technical description accurate? %%
"I think I've found it," she said.
`;
const TEST_STYLE_GUIDE = `# Style Guide
First person. Past tense. Short sentences.`;
const TEST_VOICE_TESTS = `# Voice Tests
## Protagonist Solo (with Rabbit)
"Okay, Rabbit. Here's the thing about garbage collection."
## Protagonist with Tim
"You need to eat something that isn't coffee."
`;
const TEST_SETTINGS: PennySettings = {
...DEFAULT_SETTINGS,
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
proseMarker: "<!-- Prose begins below -->",
};
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: TEST_VOICE_TESTS,
styleGuide: TEST_STYLE_GUIDE,
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
/**
* Create a mock provider that returns predetermined text for each call.
* Calls are answered in order from the responses array.
*/
function createMockProvider(responses: string[]) {
let callIndex = 0;
const calls: CompletionRequest[] = [];
return {
provider: {
name: "mock",
requiresApiKey: false,
async complete(request: CompletionRequest): Promise<CompletionResponse> {
calls.push(request);
const text = responses[callIndex] ?? `[mock response ${callIndex}]`;
callIndex++;
return {
text,
model: request.model,
provider: "mock",
usage: { inputTokens: 100, outputTokens: 50 },
};
},
estimateTokens(text: string): number {
return text.split(/\s+/).length;
},
async getModels() {
return [];
},
async testConnection() {
return null;
},
},
getCalls: () => calls,
};
}
function makePipelineInput(
content: string,
versionContent: string,
stateContent: string,
mockProvider: ReturnType<typeof createMockProvider>,
): PipelineInput {
return {
content,
versionContent,
stateContent,
contextFiles: makeContextFiles(content),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: (name: string) => {
// Return the mock provider for any provider name
return mockProvider.provider;
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("runPipeline (integration)", () => {
it("processes multiple annotation types and produces a versioned output", async () => {
const mock = createMockProvider([
// Response for REWRITE annotation
"The lab hummed at 3am. The fluorescent lights buzzed overhead, casting everything in that flat institutional glow. The screen threw blue light across my face. Somewhere a fan clicked. The building smelled like old carpet and ozone.",
// Response for EXPAND annotation
'"I don\'t understand what I\'m seeing," I said to Rabbit.\n\n"The numbers keep shifting. Every time I think I\'ve got the pattern, it moves. Like it knows I\'m watching."\n\nI pulled my coffee closer. Chairman Meow stretched across the keyboard.\n\n"That\'s not helping," I told him. He didn\'t care.',
]);
const input = makePipelineInput(TEST_CHAPTER, "1", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Version should increment
expect(r.newVersion).toBe(2);
// Should have processed 2 actionable annotations (REWRITE + EXPAND)
expect(r.annotationsProcessed).toBe(2);
// Passthrough annotations (NOTE + RESEARCH) should be counted as skipped
expect(r.annotationsSkipped).toBe(2);
// Tags should be tracked
expect(r.tags).toContain("REWRITE");
expect(r.tags).toContain("EXPAND");
// No errors expected
expect(r.hadErrors).toBe(false);
// The new content should contain REVISED markers
expect(r.newContent).toContain("REVISED(v2)");
expect(r.newContent).toContain("[REWRITE]");
expect(r.newContent).toContain("[EXPAND]");
// The new content should NOT contain the original actionable annotations
expect(r.newContent).not.toContain("%% REWRITE:");
expect(r.newContent).not.toContain("%% EXPAND:");
// The passthrough annotations should be preserved
expect(r.newContent).toContain("%% NOTE:");
expect(r.newContent).toContain("%% RESEARCH:");
// The revised text should be in the output
expect(r.newContent).toContain("fluorescent lights buzzed");
expect(r.newContent).toContain("Chairman Meow stretched");
// The mock provider should have been called twice
expect(mock.getCalls()).toHaveLength(2);
// State JSON should track the processed annotations
const state = JSON.parse(r.stateJson);
expect(state.version).toBe(2);
expect(state.processedAnnotations).toHaveLength(2);
expect(state.processedAnnotations[0].tag).toBeDefined();
expect(state.processedAnnotations[0].hash).toBeDefined();
// Review content should be valid markdown
expect(r.reviewContent).toContain("# ch-05 Review -- v2");
expect(r.reviewContent).toContain("**Annotations processed:** 2");
expect(r.reviewContent).toContain("**Annotations skipped (NOTE/RESEARCH):** 2");
expect(r.reviewContent).toContain("## Changes Made");
expect(r.reviewContent).toContain("[REWRITE]");
expect(r.reviewContent).toContain("[EXPAND]");
expect(r.reviewContent).toContain("## Voice Compliance");
expect(r.reviewContent).toContain("## Word Count");
// Log line should be valid JSONL
const logEntry = JSON.parse(r.logLine.trim());
expect(logEntry.book).toBe("book-1");
expect(logEntry.chapter).toBe("ch-05");
expect(logEntry.versionFrom).toBe(1);
expect(logEntry.versionTo).toBe(2);
expect(logEntry.annotationsProcessed).toBe(2);
});
it("returns null when there are no actionable annotations", async () => {
const contentNoAnnotations = `---
type: chapter
book: 1
chapter: 3
---
# Chapter 3
Some prose without any annotations.
%% NOTE: Just a note %%
%% RESEARCH: Something to look up %%
`;
const mock = createMockProvider([]);
const input = makePipelineInput(contentNoAnnotations, "1", "", mock);
const result = await runPipeline(input);
expect(result).toBeNull();
// Provider should never be called
expect(mock.getCalls()).toHaveLength(0);
});
it("is idempotent: second run with same state skips already-processed annotations", async () => {
const mock1 = createMockProvider([
"Revised lab scene.",
"Revised dialogue scene.",
]);
// First run
const input1 = makePipelineInput(TEST_CHAPTER, "1", "", mock1);
const result1 = await runPipeline(input1);
expect(result1).not.toBeNull();
expect(result1!.annotationsProcessed).toBe(2);
// Second run with the state from the first run
const mock2 = createMockProvider([]);
const input2 = makePipelineInput(TEST_CHAPTER, "2", result1!.stateJson, mock2);
const result2 = await runPipeline(input2);
// Should return null -- nothing new to process
expect(result2).toBeNull();
// Provider should not have been called
expect(mock2.getCalls()).toHaveLength(0);
});
it("handles provider errors gracefully with AGENT-ERROR markers", async () => {
// First call succeeds, second call throws
let callCount = 0;
const failingProvider = {
name: "failing",
requiresApiKey: false,
async complete(request: CompletionRequest): Promise<CompletionResponse> {
callCount++;
if (callCount === 2) {
throw new Error("Rate limit exceeded");
}
return {
text: "Revised text for first annotation.",
model: request.model,
provider: "failing",
};
},
estimateTokens(text: string) {
return text.split(/\s+/).length;
},
async getModels() {
return [];
},
async testConnection() {
return null;
},
};
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => failingProvider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Should still produce output (not throw)
expect(r.newVersion).toBe(2);
expect(r.hadErrors).toBe(true);
// The output should contain the AGENT-ERROR marker for the failed annotation
expect(r.newContent).toContain("AGENT-ERROR");
expect(r.newContent).toContain("Rate limit exceeded");
// The review should flag the error
expect(r.reviewContent).toContain("error");
// Only 1 annotation should be counted as successfully processed
expect(r.annotationsProcessed).toBe(1);
});
it("handles missing provider gracefully", async () => {
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => undefined, // No provider available
};
const result = await runPipeline(input);
// When all annotations fail, pipeline returns null (no version created)
expect(result).toBeNull();
});
it("updates frontmatter with agent fields", async () => {
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
const input = makePipelineInput(TEST_CHAPTER, "1", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Check frontmatter fields in the output
expect(r.newContent).toContain("agent_version: 2");
expect(r.newContent).toContain("agent_last_revised:");
expect(r.newContent).toContain("wordcount:");
});
it("builds prompts with context from provided files", async () => {
const mock = createMockProvider(["Revised passage."]);
// Use a chapter with a single annotation for simpler verification
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
focus: tim
---
# Chapter 1
<!-- Prose begins below -->
She walked in.
%% REWRITE: Add sensory detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: {
chapter: simpleChapter,
voiceTests: TEST_VOICE_TESTS,
styleGuide: TEST_STYLE_GUIDE,
outline: "Plot outline here.",
characters: ["Tim character sheet."],
wiki: [],
},
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
// Verify the prompt sent to the provider contained context
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
const call = calls[0];
// System prompt should contain context from files
expect(call.systemPrompt).toContain("Style Guide");
expect(call.systemPrompt).toContain("First person. Past tense.");
// User prompt should contain the task details
expect(call.userPrompt).toContain("REWRITE");
expect(call.userPrompt).toContain("Add sensory detail");
expect(call.userPrompt).toContain("She walked in.");
// System prompt should NOT have the task/passage/instruction
// (those were removed from the template -- LOW-2 fix)
expect(call.systemPrompt).not.toContain("Passage to Revise");
expect(call.systemPrompt).not.toContain("Author's Instruction");
});
it("starts from version 0 when no .version file exists", async () => {
const mock = createMockProvider(["Revised."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Fix this %%
`;
const input = makePipelineInput(simpleChapter, "", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
// readVersion("") returns 0, so nextVersion should be 1
expect(result!.newVersion).toBe(1);
});
it("uses non-default maxTokens (8000) and passes it to the provider", async () => {
const mock = createMockProvider(["Revised with lower token limit."]);
const settingsWithLowTokens: PennySettings = {
...TEST_SETTINGS,
maxTokens: 8000,
};
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: settingsWithLowTokens,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("returns null when cancelled before any annotation is processed", async () => {
const mock = createMockProvider([
"Should never be used.",
"Also should never be used.",
]);
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => mock.provider,
isCancelled: () => true, // Cancelled from the start
};
const result = await runPipeline(input);
// Should return null -- cancelled before any work
expect(result).toBeNull();
// Provider should never be called
expect(mock.getCalls()).toHaveLength(0);
});
it("returns null when cancelled after first annotation (no partial version)", async () => {
let callCount = 0;
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
// Cancel after the first annotation completes
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => {
callCount++;
return mock.provider;
},
isCancelled: () => callCount >= 1, // Cancel after first annotation starts
};
const result = await runPipeline(input);
// Should return null -- cancelled run must not produce a version
expect(result).toBeNull();
});
it("emits a cancelled event with Cancelled message when cancelled mid-run", async () => {
const progressEvents: Array<{ type: string; message?: string }> = [];
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => mock.provider,
onProgress: (event) => {
progressEvents.push({ type: event.type, message: event.message });
},
isCancelled: () => {
// Cancel after at least one provider call has been made
return mock.getCalls().length >= 1;
},
};
const result = await runPipeline(input);
// Should return null
expect(result).toBeNull();
// Should have emitted a "cancelled" event (distinct from "complete")
const cancelledEvents = progressEvents.filter((e) => e.type === "cancelled");
expect(cancelledEvents.length).toBeGreaterThanOrEqual(1);
expect(cancelledEvents.some((e) => e.message?.includes("Cancelled"))).toBe(true);
// And should NOT have emitted a "complete" event -- a cancelled run is not complete
const completeEvents = progressEvents.filter((e) => e.type === "complete");
expect(completeEvents.length).toBe(0);
});
it("produces valid file outputs for upsert on first run (create path)", async () => {
const mock = createMockProvider(["Revised passage for version file."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
// First run: empty version and state content (files don't exist yet)
const input: PipelineInput = {
content: simpleChapter,
versionContent: "",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// newVersion is a number that would be written to the .version file
expect(r.newVersion).toBe(1);
// stateJson is valid JSON that would be written to .state.json
const state = JSON.parse(r.stateJson);
expect(state.version).toBe(1);
expect(state.processedAnnotations).toHaveLength(1);
expect(state.lastProcessed).toBeDefined();
// newContent is the full chapter content that replaces the original
expect(r.newContent).toContain("REVISED(v1)");
// reviewContent is the markdown review note
expect(r.reviewContent.length).toBeGreaterThan(0);
expect(r.reviewContent).toContain("# ch-01 Review -- v1");
});
it("produces valid file outputs for upsert on subsequent run (modify path)", async () => {
const mock1 = createMockProvider(["First revision."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
// First run to get state
const input1 = makePipelineInput(simpleChapter, "", "", mock1);
const result1 = await runPipeline(input1);
expect(result1).not.toBeNull();
// Now simulate a second run where the files already exist:
// The version file contains "1", the state file has the first run's state.
// Add a new annotation to the already-processed content.
const secondChapter = result1!.newContent + "\n\nNew paragraph.\n%% EXPAND: expand this %%\n";
const mock2 = createMockProvider(["Expanded new paragraph with detail."]);
const input2: PipelineInput = {
content: secondChapter,
versionContent: String(result1!.newVersion),
stateContent: result1!.stateJson,
contextFiles: makeContextFiles(secondChapter),
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock2.provider,
};
const result2 = await runPipeline(input2);
expect(result2).not.toBeNull();
const r2 = result2 as PipelineResult;
// Version increments -- this is the value that upsertFile would overwrite
expect(r2.newVersion).toBe(2);
// State accumulates -- upsertFile overwrites the state file
const state2 = JSON.parse(r2.stateJson);
expect(state2.version).toBe(2);
// Should have the original processed annotation plus the new one
expect(state2.processedAnnotations.length).toBeGreaterThanOrEqual(2);
// The review is for version 2
expect(r2.reviewContent).toContain("v2");
});
it("works with empty customVoiceRules", async () => {
const mock = createMockProvider(["Revised passage."]);
const settingsEmptyVoice: PennySettings = {
...TEST_SETTINGS,
customVoiceRules: "",
};
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: settingsEmptyVoice,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
// System prompt should still be valid (non-empty)
expect(calls[0].systemPrompt.length).toBeGreaterThan(0);
// The prompt should still function without voice rules
expect(calls[0].userPrompt).toContain("REWRITE");
});
});