From 29f7b57fdeb55b6dafed72a9829f561d24189bca Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 21 Jun 2026 00:29:17 +0200 Subject: [PATCH] feat: migrated core rendering and context transformation to async - Updated `render`, `renderMany`, and native snapcompact methods to return promises, ensuring scalable async execution. - Refactored `transformProviderContext` and `buildSideRequestContext` to support asynchronous operations in agent loops. - Integrated `Promise.all` for improved concurrency when processing frame rendering and rendering batch operations. - Updated all internal call sites, SDK hooks, and test suites to accommodate the asynchronous API signatures. --- crates/pi-natives/src/snapcompact.rs | 104 ++++++++++------- packages/agent/CHANGELOG.md | 5 +- packages/agent/src/agent-loop.ts | 2 +- packages/agent/src/agent.ts | 8 +- packages/agent/src/types.ts | 2 +- .../test/agent-side-request-context.test.ts | 14 +-- packages/coding-agent/CHANGELOG.md | 4 +- .../components/snapcompact-shape-preview.ts | 2 +- packages/coding-agent/src/sdk.ts | 4 +- .../coding-agent/src/session/agent-session.ts | 2 +- .../src/session/snapcompact-inline.ts | 15 ++- .../test/agent-session-handoff.test.ts | 3 + ...issue-986-compaction-auth-fallback.test.ts | 2 +- .../test/snapcompact-inline.test.ts | 63 +++++----- packages/natives/CHANGELOG.md | 5 +- packages/natives/native/index.d.ts | 12 +- packages/snapcompact/CHANGELOG.md | 5 +- .../snapcompact/research/parity_render.ts | 2 +- packages/snapcompact/research/render_pages.ts | 2 +- packages/snapcompact/src/snapcompact.ts | 108 ++++++++++-------- packages/snapcompact/test/snapcompact.test.ts | 57 +++++---- 21 files changed, 236 insertions(+), 185 deletions(-) diff --git a/crates/pi-natives/src/snapcompact.rs b/crates/pi-natives/src/snapcompact.rs index e7966f88f..8bf0e6cb6 100644 --- a/crates/pi-natives/src/snapcompact.rs +++ b/crates/pi-natives/src/snapcompact.rs @@ -44,6 +44,8 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use napi::bindgen_prelude::*; use napi_derive::napi; +use crate::task; + /// Upper bound on the frame edge: a hard stop against absurd allocations /// (`size * size` pixel buffer), far above the 2576px production frame. const MAX_FRAME_SIZE: u32 = 16384; @@ -673,8 +675,8 @@ pub struct SnapcompactRenderOptions { pub columns: Option, } -/// Render one snapcompact frame: print pre-normalized text onto a -/// `size`-wide bitmap and encode it as PNG. +/// Render one snapcompact frame on a libuv worker: print pre-normalized text +/// onto a `size`-wide bitmap and encode it as PNG. /// /// The bitmap height hugs the rows the text actually occupies /// (`usedRows * lineRepeat * cellHeight`), so a partially filled frame never @@ -686,13 +688,20 @@ pub struct SnapcompactRenderOptions { /// requested cell box; `columns: 2` flows pre-wrapped newline-separated lines /// down two newspaper columns. `U+000E`/`U+000F` in `text` toggle dim-gray ink /// spans without occupying a cell. -/// Returns the PNG encoded as base64, created as a one-byte (Latin-1) JS -/// string straight from native code — no `Uint8Array` hop or JS-side -/// re-encode. +/// Returns a promise for the PNG encoded as base64, created as a one-byte +/// (Latin-1) JS string straight from native code — no `Uint8Array` hop or +/// JS-side re-encode. #[napi] pub fn render_snapcompact_png( text: String, options: SnapcompactRenderOptions, +) -> task::Promise { + task::blocking("render_snapcompact_png", (), move |_| render_snapcompact_png_sync(text, options)) +} + +fn render_snapcompact_png_sync( + text: String, + options: SnapcompactRenderOptions, ) -> Result { let size = options.size; if size == 0 || size > MAX_FRAME_SIZE { @@ -907,7 +916,7 @@ mod tests { #[test] fn render_native_is_indexed_and_stretch_is_rgb() { let native = png_bytes( - render_snapcompact_png("Hello world. Again.".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("Hello world. Again.".into(), SnapcompactRenderOptions { size: 128, font: Some("8x8".into()), variant: Some("bw".into()), @@ -920,7 +929,7 @@ mod tests { assert_eq!(native[25], 3); let stretched = png_bytes( - render_snapcompact_png("Hello world. Again.".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("Hello world. Again.".into(), SnapcompactRenderOptions { size: 128, font: Some("8x8".into()), cell_width: Some(6), @@ -931,7 +940,7 @@ mod tests { ); // 2 = truecolor RGB. assert_eq!(stretched[25], 2); - let legacy = png_bytes(render_snapcompact_png("Hi. Ok.".into(), opts(40)).unwrap()); + let legacy = png_bytes(render_snapcompact_png_sync("Hi. Ok.".into(), opts(40)).unwrap()); assert_eq!(legacy[25], 3, "default shape stays the legacy 5x8 indexed path"); } @@ -950,7 +959,7 @@ mod tests { // Plain bw, no dim/band/repeat: background + black ink = 1-bit. let bw = png_bytes( - render_snapcompact_png("Hello world. Again.".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("Hello world. Again.".into(), SnapcompactRenderOptions { size: 128, font: Some("8x8".into()), variant: Some("bw".into()), @@ -962,7 +971,7 @@ mod tests { // bw with a dim span and repeat bands: 4 colors = 2-bit. let dim = png_bytes( - render_snapcompact_png( + render_snapcompact_png_sync( "Read \u{e}the dim part\u{f} now.".into(), SnapcompactRenderOptions { size: 128, @@ -979,7 +988,7 @@ mod tests { // Sentence hues exceed 4 colors: stays 4-bit, palette still narrowed // to the inks actually printed (bg + 2 hues here). let sent = png_bytes( - render_snapcompact_png("Hi. Ok.".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("Hi. Ok.".into(), SnapcompactRenderOptions { size: 128, font: Some("8x8".into()), variant: Some("sent".into()), @@ -994,9 +1003,9 @@ mod tests { #[test] fn rejects_bad_shapes() { - assert!(render_snapcompact_png("x".into(), opts(0)).is_err()); + assert!(render_snapcompact_png_sync("x".into(), opts(0)).is_err()); assert!( - render_snapcompact_png("x".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("x".into(), SnapcompactRenderOptions { size: 64, font: Some("9x9".into()), ..Default::default() @@ -1004,7 +1013,7 @@ mod tests { .is_err() ); assert!( - render_snapcompact_png("x".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("x".into(), SnapcompactRenderOptions { size: 64, variant: Some("zebra".into()), ..Default::default() @@ -1023,7 +1032,7 @@ mod tests { } for (name, size) in [("6x12", 60u32), ("8x13", 104u32)] { let png = png_bytes( - render_snapcompact_png("Hello world. Again!".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("Hello world. Again!".into(), SnapcompactRenderOptions { size, font: Some(name.into()), ..Default::default() @@ -1044,15 +1053,18 @@ mod tests { #[test] fn stretch_false_renders_natural_glyphs_on_padded_pitch() { let png = png_bytes( - render_snapcompact_png("Hello there. General Kenobi!".into(), SnapcompactRenderOptions { - size: 128, - font: Some("8x13".into()), - cell_width: Some(8), - cell_height: Some(16), - stretch: Some(false), - variant: Some("bw".into()), - ..Default::default() - }) + render_snapcompact_png_sync( + "Hello there. General Kenobi!".into(), + SnapcompactRenderOptions { + size: 128, + font: Some("8x13".into()), + cell_width: Some(8), + cell_height: Some(16), + stretch: Some(false), + variant: Some("bw".into()), + ..Default::default() + }, + ) .unwrap(), ); assert_eq!(png[25], 3, "8on16 must stay indexed"); @@ -1117,7 +1129,7 @@ mod tests { (dim(16), dim(20)) }; let render = |text: &str, opts: SnapcompactRenderOptions| { - png_bytes(render_snapcompact_png(text.into(), opts).unwrap()) + png_bytes(render_snapcompact_png_sync(text.into(), opts).unwrap()) }; let opts_8x8 = || SnapcompactRenderOptions { size: 64, font: Some("8x8".into()), ..Default::default() }; @@ -1156,7 +1168,7 @@ mod tests { #[test] fn columns_validates_and_renders_doc_frames() { assert!( - render_snapcompact_png("x".into(), SnapcompactRenderOptions { + render_snapcompact_png_sync("x".into(), SnapcompactRenderOptions { size: 64, columns: Some(3), ..Default::default() @@ -1165,28 +1177,34 @@ mod tests { ); // Indexed doc frame (stretch: false on a padded pitch). let doc = png_bytes( - render_snapcompact_png("Hello there.\nSecond line".into(), SnapcompactRenderOptions { - size: 256, - font: Some("8x13".into()), - cell_width: Some(8), - cell_height: Some(16), - stretch: Some(false), - columns: Some(2), - ..Default::default() - }) + render_snapcompact_png_sync( + "Hello there.\nSecond line".into(), + SnapcompactRenderOptions { + size: 256, + font: Some("8x13".into()), + cell_width: Some(8), + cell_height: Some(16), + stretch: Some(false), + columns: Some(2), + ..Default::default() + }, + ) .unwrap(), ); assert_eq!(doc[25], 3, "8on16 doc frame must encode indexed"); // Doc layout also applies on the stretch path (RGB output). let stretched = png_bytes( - render_snapcompact_png("Hello there.\nSecond line".into(), SnapcompactRenderOptions { - size: 256, - font: Some("8x13".into()), - cell_width: Some(6), - cell_height: Some(12), - columns: Some(2), - ..Default::default() - }) + render_snapcompact_png_sync( + "Hello there.\nSecond line".into(), + SnapcompactRenderOptions { + size: 256, + font: Some("8x13".into()), + cell_width: Some(6), + cell_height: Some(12), + columns: Some(2), + ..Default::default() + }, + ) .unwrap(), ); assert_eq!(stretched[25], 2, "stretched doc frame must encode RGB"); diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bdfd9fa07..501e0cbb7 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed `transformProviderContext` and `buildSideRequestContext` to return a Promise ### Added @@ -910,4 +913,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Changed - `Agent` constructor now has all options optional (empty options use defaults). -- `queueMessage()` is now synchronous (no longer returns a Promise). +- `queueMessage()` is now synchronous (no longer returns a Promise). \ No newline at end of file diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index c3336c8b2..31f3124ad 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1161,7 +1161,7 @@ async function streamAssistantResponse( }; } if (config.transformProviderContext) { - llmContext = config.transformProviderContext(llmContext, config.model); + llmContext = await config.transformProviderContext(llmContext, config.model); } // Owned tool calling: take tool calls away from the provider and run them diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 58fdde451..da13be0e9 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -110,7 +110,7 @@ export interface AgentOptions { * Optional transform applied after provider context assembly and before * telemetry capture/provider send. */ - transformProviderContext?: (context: Context, model: Model) => Context; + transformProviderContext?: (context: Context, model: Model) => Context | Promise; /** * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn @@ -328,7 +328,7 @@ export class Agent { #abortController?: AbortController; #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - #transformProviderContext?: (context: Context, model: Model) => Context; + #transformProviderContext?: (context: Context, model: Model) => Context | Promise; #steeringQueue: AgentMessage[] = []; #followUpQueue: AgentMessage[] = []; #steeringMode: "all" | "one-at-a-time"; @@ -678,7 +678,7 @@ export class Agent { * shape and avoiding tool-markup leakage). `llmMessages` is already converted * (and, in production, obfuscated) by the caller. */ - buildSideRequestContext(llmMessages: Message[]): Context { + async buildSideRequestContext(llmMessages: Message[]): Promise { const model = this.#state.model; if (!model) throw new Error("No active model on agent"); const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT); @@ -692,7 +692,7 @@ export class Agent { this.#pruneToolDescriptions, ) ?? []); let context: Context = { systemPrompt: this.#state.systemPrompt, messages, tools }; - if (this.#transformProviderContext) context = this.#transformProviderContext(context, model); + if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model); return context; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index a9044bf9c..60a81e918 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -155,7 +155,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * normalization, and append-only context handling, but before telemetry capture * and provider send. */ - transformProviderContext?: (context: Context, model: Model) => Context; + transformProviderContext?: (context: Context, model: Model) => Context | Promise; /** * Resolves the API key or resolver for the current model before each LLM call. diff --git a/packages/agent/test/agent-side-request-context.test.ts b/packages/agent/test/agent-side-request-context.test.ts index b8472681d..3c38d5652 100644 --- a/packages/agent/test/agent-side-request-context.test.ts +++ b/packages/agent/test/agent-side-request-context.test.ts @@ -50,7 +50,7 @@ describe("Agent — buildSideRequestContext", () => { }; it("forwards the tool catalog for native providers", async () => { - await withNativeDialectEnv(() => { + await withNativeDialectEnv(async () => { const agent = new Agent({ initialState: { model, @@ -59,7 +59,7 @@ describe("Agent — buildSideRequestContext", () => { }, }); - const context = agent.buildSideRequestContext([ + const context = await agent.buildSideRequestContext([ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() }, ]); @@ -100,7 +100,7 @@ describe("Agent — buildSideRequestContext", () => { tools: [tool], }, }); - const sideContext = sideAgent.buildSideRequestContext([ + const sideContext = await sideAgent.buildSideRequestContext([ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() }, ]); @@ -109,7 +109,7 @@ describe("Agent — buildSideRequestContext", () => { }); }); - it("returns empty tools when owned dialect is active", () => { + it("returns empty tools when owned dialect is active", async () => { const agent = new Agent({ initialState: { model, @@ -119,7 +119,7 @@ describe("Agent — buildSideRequestContext", () => { dialect: "glm", }); - const context = agent.buildSideRequestContext([ + const context = await agent.buildSideRequestContext([ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() }, ]); @@ -127,7 +127,7 @@ describe("Agent — buildSideRequestContext", () => { expect(context.systemPrompt).toEqual(["system"]); }); - it("invokes transformProviderContext filter if present", () => { + it("invokes transformProviderContext filter if present", async () => { const transformSpy = mock((ctx: Context): Context => { return { ...ctx, @@ -144,7 +144,7 @@ describe("Agent — buildSideRequestContext", () => { transformProviderContext: transformSpy, }); - const context = agent.buildSideRequestContext([ + const context = await agent.buildSideRequestContext([ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() }, ]); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8b8229711..849fb4956 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added "Prose Only Thinking" setting to opt-out of rendering code blocks within AI thinking traces @@ -12,6 +11,7 @@ ### Changed +- Updated `SnapcompactInline` and SDK session context building to support asynchronous rendering - Changed side-channel turns (`/btw`, `/omfg`, and IRC auto-replies) to forward the main turn's tool catalog to preserve the prompt-cache layout, while injecting a reminder to suppress tool usage and discarding any generated tool calls. - Changed `/btw`, `/tan`, `/omfg`, `/memory`, `/rename`, and `/move` to save the typed command text to TUI prompt history so they can be recalled with the up arrow. - Changed the temporary model picker to label Alt+P selections as session-only and point users to Alt+M or `/model` for role model assignment. ([#2952](https://github.com/can1357/oh-my-pi/issues/2952)) @@ -12208,4 +12208,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. +Previous releases did not maintain a changelog. \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts index 6eddcb41f..681c97eea 100644 --- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts +++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts @@ -148,7 +148,7 @@ export class SnapcompactShapePreview implements Component { while (normalize(text).length < capacity) { text += ` ${PREVIEW_TEXT}`; } - const frame = renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 })[0]; + const frame = (await renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 }))[0]; if (!frame) throw new Error("empty sample frame"); const edgePx = SRC_FRAME_PX * ZOOM_SCALE; const zoomed = await new Bun.Image(Buffer.from(frame.data, "base64")) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 7c66c3657..d4133af98 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2416,9 +2416,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} : undefined; const transformProviderContext = obfuscator || snapcompactInline - ? (context: Context, transformModel: Model): Context => { + ? async (context: Context, transformModel: Model): Promise => { let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context; - if (snapcompactInline) transformed = snapcompactInline.transform(transformed, transformModel); + if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel); return transformed; } : undefined; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 3d2221ee5..debbfc65f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -11327,7 +11327,7 @@ export class AgentSession { const cacheSessionId = this.sessionId; const snapshot = this.#buildEphemeralSnapshot(args.promptText); const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal); - const context = this.agent.buildSideRequestContext(llmMessages); + const context = await this.agent.buildSideRequestContext(llmMessages); const options = this.prepareSimpleStreamOptions( { apiKey: this.#modelRegistry.resolver(model, cacheSessionId), diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index 10f8588ac..7345053c1 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -414,7 +414,7 @@ export class SnapcompactInlineTransformer { private readonly onToolResultSavings?: SnapcompactSavingsSink, ) {} - transform(context: Context, model: Model): Context { + async transform(context: Context, model: Model): Promise { // Vision gate: providers silently DROP images on text-only models — // rendering would lose the content entirely. if (!model.input.includes("image")) return context; @@ -482,7 +482,7 @@ export class SnapcompactInlineTransformer { for (const swap of plan.toolResults) { const target = targets.get(swap.id); if (!target) continue; - const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape); + const frames = await this.#framesFor(this.#toolCache, swap.id, target.text, shape); messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] }; changed = true; savings.push({ @@ -506,7 +506,10 @@ export class SnapcompactInlineTransformer { if (!cached || cached.hash !== hash) { cached = { hash, - frames: snapcompact.renderMany(systemPromptTarget.text, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }), + frames: await snapcompact.renderMany(systemPromptTarget.text, { + shape, + maxFrames: MAX_SYSTEM_PROMPT_FRAMES, + }), }; this.#systemCache = cached; } @@ -526,16 +529,16 @@ export class SnapcompactInlineTransformer { return { ...context, systemPrompt, messages }; } - #framesFor( + async #framesFor( cache: Map, key: string, text: string, shape: snapcompact.Shape, - ): ImageContent[] { + ): Promise { const hash = Bun.hash(text); const cached = cache.get(key); if (cached && cached.hash === hash) return cached.frames; - const frames = snapcompact.renderMany(text, { shape }); + const frames = await snapcompact.renderMany(text, { shape }); cache.set(key, { hash, frames }); return frames; } diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 02c78bef2..85d85eb6d 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -265,6 +265,7 @@ describe("AgentSession handoff", () => { }); it("obfuscates previous compaction summary and preserve data before forwarding to compact()", async () => { + session.settings.set("compaction.strategy", "context-full"); const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; @@ -307,6 +308,7 @@ describe("AgentSession handoff", () => { }); it("runs context maintenance before sending an oversized pending prompt", async () => { + session.settings.set("compaction.strategy", "context-full"); session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); session.settings.set("contextPromotion.enabled", false); @@ -336,6 +338,7 @@ describe("AgentSession handoff", () => { }); it("falls back after one auto-compaction timeout instead of retrying the same model", async () => { + session.settings.set("compaction.strategy", "context-full"); session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); session.settings.set("contextPromotion.enabled", false); diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 67c5c951f..e49551a73 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -37,7 +37,7 @@ describe("issue #986 compaction auth fallback", () => { throw new Error("Expected bundled test models to exist"); } - const settings = Settings.isolated({ "compaction.keepRecentTokens": 1 }); + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); if (options?.fallbackModelRole) { settings.setModelRole(options.fallbackModelRole, `${fallbackModel.provider}/${fallbackModel.id}`); } diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 7d5d8b65d..c22508d8b 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -92,20 +92,20 @@ function imageCount(context: Context): number { } describe("SnapcompactInlineTransformer", () => { - it("is a no-op for text-only models", () => { + it("is a no-op for text-only models", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "all", renderToolResults: true }), ); const context = makeContext(); - expect(transformer.transform(context, makeModel({ input: ["text"] }))).toBe(context); + expect(await transformer.transform(context, makeModel({ input: ["text"] }))).toBe(context); }); - it("images large historical tool results, keeping small and most-recent ones as text", () => { + it("images large historical tool results, keeping small and most-recent ones as text", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), ); const context = makeContext(); - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); // Large historical result → leading text note + image frames. const imaged = result.messages[1] as ToolResultMessage; @@ -126,7 +126,7 @@ describe("SnapcompactInlineTransformer", () => { expect(result.systemPrompt).toBe(context.systemPrompt); }); - it("reports per-tool-result savings to the sink for each imaged result only", () => { + it("reports per-tool-result savings to the sink for each imaged result only", async () => { const received: Array<{ toolCallId: string; savedTokens: number }>[] = []; let model = ""; const transformer = new SnapcompactInlineTransformer( @@ -136,7 +136,7 @@ describe("SnapcompactInlineTransformer", () => { model = m.id; }, ); - transformer.transform(makeContext(), makeModel()); + await transformer.transform(makeContext(), makeModel()); // Only the large historical result (call_1) is imaged; call_2 is small, // call_3 is the most-recent (kept crisp). @@ -147,7 +147,7 @@ describe("SnapcompactInlineTransformer", () => { expect(model).toBe("test-model"); }); - it("never calls the savings sink when nothing is imaged", () => { + it("never calls the savings sink when nothing is imaged", async () => { let calls = 0; const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), @@ -156,11 +156,11 @@ describe("SnapcompactInlineTransformer", () => { }, ); // Text-only model → vision gate short-circuits before any swap. - transformer.transform(makeContext(), makeModel({ input: ["text"] })); + await transformer.transform(makeContext(), makeModel({ input: ["text"] })); expect(calls).toBe(0); }); - it("never mutates the input context (persisted history shares these references)", () => { + it("never mutates the input context (persisted history shares these references)", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "all", renderToolResults: true }), ); @@ -170,7 +170,7 @@ describe("SnapcompactInlineTransformer", () => { const original = context.messages[1] as ToolResultMessage; const originalContent = original.content; - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); expect(result).not.toBe(context); expect(context.messages).toBe(originalMessages); @@ -181,7 +181,7 @@ describe("SnapcompactInlineTransformer", () => { expect((context.messages[0] as { content: string }).content).toBe("first user prompt"); }); - it("leaves tool results that already carry images untouched", () => { + it("leaves tool results that already carry images untouched", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), ); @@ -195,10 +195,10 @@ describe("SnapcompactInlineTransformer", () => { const context: Context = { messages: [userMessage("hi"), withImage, toolResult("call_tail", LARGE)], }; - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); expect(result.messages[1]).toBe(withImage); }); - it("leaves error tool results text-only even when they are large", () => { + it("leaves error tool results text-only even when they are large", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), ); @@ -206,14 +206,14 @@ describe("SnapcompactInlineTransformer", () => { const context: Context = { messages: [userMessage("hi"), errorResult, toolResult("call_tail", LARGE)], }; - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); expect(result).toBe(context); expect(result.messages[1]).toBe(errorResult); expect(errorResult.content.every(block => block.type === "text")).toBe(true); expect(imageCount(result)).toBe(0); }); - it("replaces a large system prompt with a stub and rides frames on the first user message", () => { + it("replaces a large system prompt with a stub and rides frames on the first user message", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "all", renderToolResults: false }), ); @@ -222,7 +222,7 @@ describe("SnapcompactInlineTransformer", () => { systemPrompt: [longPrompt], messages: [userMessage("do the thing"), toolResult("call_1", SMALL)], }; - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); expect(result.systemPrompt).toHaveLength(1); expect(result.systemPrompt![0]).not.toBe(longPrompt); @@ -236,7 +236,7 @@ describe("SnapcompactInlineTransformer", () => { expect(carrier.content[carrier.content.length - 1]).toEqual({ type: "text", text: "do the thing" }); }); - it("moves only loaded context-file instructions when AGENTS.md mode is selected", () => { + it("moves only loaded context-file instructions when AGENTS.md mode is selected", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "agents-md", @@ -251,7 +251,7 @@ describe("SnapcompactInlineTransformer", () => { ], messages: [userMessage("do the thing")], }; - const result = transformer.transform(context, makeModel()); + const result = await transformer.transform(context, makeModel()); expect(result.systemPrompt).toHaveLength(2); expect(result.systemPrompt![0]).toContain("Core instructions."); @@ -266,18 +266,18 @@ describe("SnapcompactInlineTransformer", () => { expect(carrier.content[carrier.content.length - 1]).toEqual({ type: "text", text: "do the thing" }); }); - it("keeps a small system prompt as text and skips when no user message exists", () => { + it("keeps a small system prompt as text and skips when no user message exists", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "all", renderToolResults: false }), ); const small: Context = { systemPrompt: ["Be terse."], messages: [userMessage("hi")] }; - expect(transformer.transform(small, makeModel())).toBe(small); + expect(await transformer.transform(small, makeModel())).toBe(small); const noUser: Context = { systemPrompt: [denseText(3000)], messages: [toolResult("call_1", SMALL)] }; - expect(transformer.transform(noUser, makeModel())).toBe(noUser); + expect(await transformer.transform(noUser, makeModel())).toBe(noUser); }); - it("never rasterizes tool results under the 3k-token floor, even when frames are cheaper", () => { + it("never rasterizes tool results under the 3k-token floor, even when frames are cheaper", async () => { const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: "none", renderToolResults: true }); // ~1.7k soft tokens: the google shape estimates 1 frame ≈ 1120 tokens, so the // savings gate alone would rasterize this — the floor must keep it text. @@ -285,11 +285,14 @@ describe("SnapcompactInlineTransformer", () => { const context: Context = { messages: [userMessage("go"), toolResult("call_1", midsize), toolResult("call_2", LARGE)], }; - const result = transformer.transform(context, makeModel({ api: "google-generative-ai", provider: "google" })); + const result = await transformer.transform( + context, + makeModel({ api: "google-generative-ai", provider: "google" }), + ); expect(result).toBe(context); }); - it("respects the per-provider image budget for unknown providers", () => { + it("respects the per-provider image budget for unknown providers", async () => { const transformer = new SnapcompactInlineTransformer( withTestShape({ renderSystemPrompt: "none", renderToolResults: true }), ); @@ -304,13 +307,13 @@ describe("SnapcompactInlineTransformer", () => { }; // Unknown provider → default budget 5. Each LARGE needs 2 frames: // call_1 (2) + call_2 (2) fit, call_3 needs 2 > 1 remaining → text. - const result = transformer.transform(context, makeModel({ provider: "groq" })); + const result = await transformer.transform(context, makeModel({ provider: "groq" })); expect(imageCount(result)).toBeLessThanOrEqual(5); expect(result.messages[3]).toBe(context.messages[3]); expect(result.messages[4]).toBe(context.messages[4]); }); - it("caches renders across turns: identical input does not re-rasterize", () => { + it("caches renders across turns: identical input does not re-rasterize", async () => { const spy = spyOn(snapcompact, "renderMany"); try { const transformer = new SnapcompactInlineTransformer( @@ -319,11 +322,11 @@ describe("SnapcompactInlineTransformer", () => { const context = makeContext(); const model = makeModel(); - const first = transformer.transform(context, model); + const first = await transformer.transform(context, model); const callsAfterFirst = spy.mock.calls.length; expect(callsAfterFirst).toBeGreaterThan(0); - const second = transformer.transform(context, model); + const second = await transformer.transform(context, model); expect(spy.mock.calls.length).toBe(callsAfterFirst); const firstFrames = (first.messages[1] as ToolResultMessage).content.slice(1); @@ -487,7 +490,7 @@ describe("estimateInlineSavings", () => { expect(estimate.savedTokens).toBe(0); }); - it("matches what the transform actually swaps on the same context", () => { + it("matches what the transform actually swaps on the same context", async () => { const options: SnapcompactInlineOptions = { renderSystemPrompt: "all", renderToolResults: true, @@ -502,7 +505,7 @@ describe("estimateInlineSavings", () => { systemPrompt: context.systemPrompt!, messages: context.messages, }); - const result = new SnapcompactInlineTransformer(withTestShape(options)).transform(context, model); + const result = await new SnapcompactInlineTransformer(withTestShape(options)).transform(context, model); let imaged = 0; for (const message of result.messages) { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index f7e6c2f28..acccbf3fc 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed renderSnapcompactPng to return a promise instead of a string value ### Fixed @@ -835,4 +838,4 @@ ### Fixed -- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries +- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries \ No newline at end of file diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 7348517d8..c87816ee8 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1302,8 +1302,8 @@ export interface PtyStartOptions { export declare function readImageFromClipboard(): Promise /** - * Render one snapcompact frame: print pre-normalized text onto a - * `size`-wide bitmap and encode it as PNG. + * Render one snapcompact frame on a libuv worker: print pre-normalized text + * onto a `size`-wide bitmap and encode it as PNG. * * The bitmap height hugs the rows the text actually occupies * (`usedRows * lineRepeat * cellHeight`), so a partially filled frame never @@ -1315,11 +1315,11 @@ export declare function readImageFromClipboard(): Promise /** * Search content for a pattern (one-shot, compiles pattern each time). diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index 91804552b..e711c30d4 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed core rendering functions `render` and `renderMany` to be asynchronous ## [16.1.0] - 2026-06-19 @@ -131,4 +134,4 @@ ### Fixed - Fixed frame rendering at archive chunk boundaries to reopen dim spans when a chunk ends inside a dimmed tool-result segment -- Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied +- Fixed message serialization to strip user- and assistant-provided dim markers so only renderer-generated dim spans can be applied \ No newline at end of file diff --git a/packages/snapcompact/research/parity_render.ts b/packages/snapcompact/research/parity_render.ts index fbcc243e9..8986c630b 100644 --- a/packages/snapcompact/research/parity_render.ts +++ b/packages/snapcompact/research/parity_render.ts @@ -20,7 +20,7 @@ if (!snapcompact.isShape(shape)) { throw new Error(`shape json is not a complete Shape: ${shapeJson}`); } -const frames = snapcompact.renderMany(text, { shape, maxFrames: 1 }); +const frames = await snapcompact.renderMany(text, { shape, maxFrames: 1 }); if (frames.length === 0) { throw new Error("renderMany produced no frames"); } diff --git a/packages/snapcompact/research/render_pages.ts b/packages/snapcompact/research/render_pages.ts index f000f1052..0301fcf31 100644 --- a/packages/snapcompact/research/render_pages.ts +++ b/packages/snapcompact/research/render_pages.ts @@ -22,7 +22,7 @@ if (!snapcompact.isShape(shape)) { } await fs.mkdir(outDir, { recursive: true }); -const frames = snapcompact.renderMany(text, { shape }); +const frames = await snapcompact.renderMany(text, { shape }); for (let i = 0; i < frames.length; i++) { await Bun.write(path.join(outDir, `page-${String(i).padStart(3, "0")}.png`), Buffer.from(frames[i].data, "base64")); } diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 674bbc36d..646fe62be 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -1151,15 +1151,8 @@ export function geometry(shape: Shape, size: number = shape.frameSize): Geometry const NEWLINES = /\n/g; -/** Render one snapcompact frame from already-normalized text. Doc shapes - * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */ -export function render(text: string, shape: Shape, size: number = shape.frameSize): RenderedFrame { - const { cols, rows, capacity } = geometry(shape, size); - let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0); - // Doc line separators consume no cell; in the grid they print as a blank. - if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0; - const chars = Math.min(visible, capacity); - const data = renderSnapcompactPng(text, { +function nativeRenderOptions(shape: Shape, size: number) { + return { size, font: shape.font, cellWidth: shape.cellWidth, @@ -1168,7 +1161,22 @@ export function render(text: string, shape: Shape, size: number = shape.frameSiz variant: shape.variant, lineRepeat: shape.lineRepeat, columns: shape.columns, - }); + }; +} + +function renderedChars(text: string, shape: Shape, capacity: number): number { + let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0); + // Doc line separators consume no cell; in the grid they print as a blank. + if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0; + return Math.min(visible, capacity); +} + +/** Render one snapcompact frame from already-normalized text. Doc shapes + * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */ +export async function render(text: string, shape: Shape, size: number = shape.frameSize): Promise { + const { cols, rows, capacity } = geometry(shape, size); + const chars = renderedChars(text, shape, capacity); + const data = await renderSnapcompactPng(text, nativeRenderOptions(shape, size)); return { data, cols, rows, chars }; } @@ -1198,38 +1206,39 @@ export interface RenderManyOptions { /** * Render arbitrary text into snapcompact PNG frames as LLM image blocks - * (first page first). Synchronous: safe to call from per-request transforms. - * Empty/whitespace-only input yields no frames. + * (first page first). Empty/whitespace-only input yields no frames. */ -export function renderMany(text: string, options?: RenderManyOptions): ImageContent[] { +export async function renderMany(text: string, options?: RenderManyOptions): Promise { const shape = options?.shape ?? resolveShape(options?.model); const frameSize = options?.frameSize ?? shape.frameSize; const geo = geometry(shape, frameSize); const normalized = normalize(text); - const frames: ImageContent[] = []; - const push = (rendered: RenderedFrame): void => { - frames.push({ - type: "image", - data: rendered.data, - mimeType: "image/png", - ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), - }); - }; + const cap = options?.maxFrames; + // Build the per-frame texts in order first (cheap, synchronous), then fan + // the native PNG renders out concurrently — render() is async/off-thread, + // so awaiting each before starting the next leaves throughput on the table. + const pageTexts: string[] = []; if (shape.columns === 2) { const finish = pageFinisher(shape); for (const page of docPages(normalized, geo)) { - if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break; - push(render(finish(page), shape, frameSize)); + if (cap !== undefined && pageTexts.length >= cap) break; + pageTexts.push(finish(page)); + } + } else { + for (let offset = 0; offset < normalized.length; offset += geo.capacity) { + if (cap !== undefined && pageTexts.length >= cap) break; + let chunk = normalized.slice(offset, offset + geo.capacity); + if (shape.stopwordDim) chunk = dimStopwords(chunk); + pageTexts.push(chunk); } - return frames; } - for (let offset = 0; offset < normalized.length; offset += geo.capacity) { - if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break; - let chunk = normalized.slice(offset, offset + geo.capacity); - if (shape.stopwordDim) chunk = dimStopwords(chunk); - push(render(chunk, shape, frameSize)); - } - return frames; + const rendered = await Promise.all(pageTexts.map(page => render(page, shape, frameSize))); + return rendered.map(frame => ({ + type: "image", + data: frame.data, + mimeType: "image/png", + ...(shape.imageDetail ? { detail: shape.imageDetail } : {}), + })); } /** Frames needed to hold `text` at the given shape/size, without rendering. @@ -1509,34 +1518,33 @@ export async function compact( // Re-render the planned frames, carrying any open dim span across every // boundary: textHead → frames → textTail. let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF); - const newFrames: Frame[] = []; + const newFrames: Promise[] = []; for (const planned of layout.frames) { let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text; dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF); if (planned.shape.stopwordDim) pageText = dimStopwords(pageText); - const rendered = render(pageText, planned.shape); - newFrames.push({ - data: rendered.data, - mimeType: "image/png", - cols: rendered.cols, - rows: rendered.rows, - chars: rendered.chars, - font: planned.shape.font, - variant: planned.shape.variant, - lineRepeat: planned.shape.lineRepeat, - ...(planned.shape.columns === 2 ? { columns: 2 } : {}), - ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}), - ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}), - }); - // Keep the event loop responsive between native render passes. - await Bun.sleep(0); + newFrames.push( + render(pageText, planned.shape).then(rendered => ({ + data: rendered.data, + mimeType: "image/png", + cols: rendered.cols, + rows: rendered.rows, + chars: rendered.chars, + font: planned.shape.font, + variant: planned.shape.variant, + lineRepeat: planned.shape.lineRepeat, + ...(planned.shape.columns === 2 ? { columns: 2 } : {}), + ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}), + ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}), + })), + ); } const textHead = layout.textHead; const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : ""; const textChars = textHead.length + textTail.length; - const frames = newFrames; + const frames = await Promise.all(newFrames); const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars; const mixedShapes = frames.some( frame => diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 448b3cd4a..fa5c902ba 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -373,11 +373,11 @@ describe("shape resolution", () => { }); describe("render", () => { - it("produces an indexed PNG of the declared geometry with sentence-cycled ink (legacy 5x8)", () => { + it("produces an indexed PNG of the declared geometry with sentence-cycled ink (legacy 5x8)", async () => { const geometry = snapcompact.geometry(snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); expect(geometry).toEqual({ cols: 64, rows: 40, capacity: 2560 }); - const frame = snapcompact.render( + const frame = await snapcompact.render( "First sentence here. Second one differs.", snapcompact.SHAPES.legacy, TEST_FRAME_SIZE, @@ -400,12 +400,12 @@ describe("render", () => { expect(used.has(3)).toBe(false); }); - it("renders the repeated grid with doubled lines, black ink, and highlight bands", () => { + it("renders the repeated grid with doubled lines, black ink, and highlight bands", async () => { const repeated = snapcompact.resolveShape({ api: "anthropic-messages" }, "8x8r-bw"); const geometry = snapcompact.geometry(repeated, TEST_FRAME_SIZE); expect(geometry).toEqual({ cols: 40, rows: 20, capacity: 800 }); - const frame = snapcompact.render("Hello world. Again.", repeated, TEST_FRAME_SIZE); + const frame = await snapcompact.render("Hello world. Again.", repeated, TEST_FRAME_SIZE); const decoded = decodePng(Buffer.from(frame.data, "base64")); expect(decoded.colorType).toBe(3); const used = new Set(decoded.pixels); @@ -414,11 +414,11 @@ describe("render", () => { expect(used.has(1)).toBe(false); // no sentence hues in bw }); - it("renders the anthropic default (tracked 8x13) in plain black, no dim or bands", () => { + it("renders the anthropic default (tracked 8x13) in plain black, no dim or bands", async () => { const geometry = snapcompact.geometry(snapcompact.SHAPES.anthropic, TEST_FRAME_SIZE); expect(geometry).toEqual({ cols: 29, rows: 20, capacity: 580 }); - const frames = snapcompact.renderMany("Reading the films of the archive. Again.", { + const frames = await snapcompact.renderMany("Reading the films of the archive. Again.", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE, }); @@ -431,9 +431,9 @@ describe("render", () => { expect(used.has(1)).toBe(false); // no sentence hues }); - it("still dims stopwords on the selectable 6x12-dim variant", () => { + it("still dims stopwords on the selectable 6x12-dim variant", async () => { const dim = snapcompact.resolveShape({ api: "anthropic-messages" }, "6x12-dim"); - const frames = snapcompact.renderMany("Reading the films of the archive. Again.", { + const frames = await snapcompact.renderMany("Reading the films of the archive. Again.", { shape: dim, frameSize: TEST_FRAME_SIZE, }); @@ -442,23 +442,27 @@ describe("render", () => { expect(used.has(9)).toBe(true); // dim gray ink for stopwords ("the", "of") }); - it("renders a stretched shape as truecolor RGB", () => { + it("renders a stretched shape as truecolor RGB", async () => { const stretched = snapcompact.resolveShape({ api: "openai-responses" }, "6x6u-sent"); - const frame = snapcompact.render("Hello world.", stretched, TEST_FRAME_SIZE); + const frame = await snapcompact.render("Hello world.", stretched, TEST_FRAME_SIZE); // IHDR color type byte: 2 = truecolor RGB (anti-aliased stretch output). expect(Buffer.from(frame.data, "base64")[25]).toBe(2); expect(frame.cols).toBe(Math.floor(TEST_FRAME_SIZE / 6)); }); - it("caps printed characters at frame capacity", () => { + it("caps printed characters at frame capacity", async () => { const { capacity } = snapcompact.geometry(snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); - const frame = snapcompact.render("x".repeat(capacity + 500), snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); + const frame = await snapcompact.render("x".repeat(capacity + 500), snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); expect(frame.chars).toBe(capacity); }); - it("fills a full pitch-black cell for the newline glyph", () => { + it("fills a full pitch-black cell for the newline glyph", async () => { // Legacy 5x8 cells: the glyph at row 0, col 1 spans x 5..10, y 0..8. - const frame = snapcompact.render(`a${snapcompact.NEWLINE_GLYPH}b`, snapcompact.SHAPES.legacy, TEST_FRAME_SIZE); + const frame = await snapcompact.render( + `a${snapcompact.NEWLINE_GLYPH}b`, + snapcompact.SHAPES.legacy, + TEST_FRAME_SIZE, + ); expect(frame.chars).toBe(3); // the block occupies exactly one cell const decoded = decodePng(Buffer.from(frame.data, "base64")); for (let y = 0; y < 8; y++) { @@ -471,36 +475,36 @@ describe("render", () => { }); describe("renderMany", () => { - it("returns no frames for empty or whitespace-only input", () => { - expect(snapcompact.renderMany("", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE })).toEqual( - [], - ); + it("returns no frames for empty or whitespace-only input", async () => { expect( - snapcompact.renderMany(" \n\t ", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE }), + await snapcompact.renderMany("", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE }), + ).toEqual([]); + expect( + await snapcompact.renderMany(" \n\t ", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE }), ).toEqual([]); expect(snapcompact.frames("", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE })).toBe(0); }); - it("pages text into image blocks matching the predicted frame count", () => { + it("pages text into image blocks matching the predicted frame count", async () => { const shape = snapcompact.SHAPES.anthropic; const { capacity } = snapcompact.geometry(shape, TEST_FRAME_SIZE); - const short = snapcompact.renderMany("hello world", { shape, frameSize: TEST_FRAME_SIZE }); + const short = await snapcompact.renderMany("hello world", { shape, frameSize: TEST_FRAME_SIZE }); expect(short).toHaveLength(1); expect(short[0].type).toBe("image"); expect(short[0].mimeType).toBe("image/png"); expect(short[0].data.length).toBeGreaterThan(0); const text = "x".repeat(capacity * 2 + 10); - const frames = snapcompact.renderMany(text, { shape, frameSize: TEST_FRAME_SIZE }); + const frames = await snapcompact.renderMany(text, { shape, frameSize: TEST_FRAME_SIZE }); expect(frames).toHaveLength(3); expect(snapcompact.frames(text, { shape, frameSize: TEST_FRAME_SIZE })).toBe(3); }); - it("honors maxFrames and propagates the shape's detail hint", () => { + it("honors maxFrames and propagates the shape's detail hint", async () => { const shape = snapcompact.SHAPES.openai; const { capacity } = snapcompact.geometry(shape, TEST_FRAME_SIZE); - const frames = snapcompact.renderMany("x".repeat(capacity * 3), { + const frames = await snapcompact.renderMany("x".repeat(capacity * 3), { shape, frameSize: TEST_FRAME_SIZE, maxFrames: 2, @@ -508,7 +512,10 @@ describe("renderMany", () => { expect(frames).toHaveLength(2); // The openai shape carries imageDetail: "original"; anthropic carries none. expect(frames[0].detail).toBe("original"); - const bw = snapcompact.renderMany("hi", { shape: snapcompact.SHAPES.anthropic, frameSize: TEST_FRAME_SIZE }); + const bw = await snapcompact.renderMany("hi", { + shape: snapcompact.SHAPES.anthropic, + frameSize: TEST_FRAME_SIZE, + }); expect(bw[0].detail).toBeUndefined(); }); });