From 050f68ac03ea65a27627517f8f72b4e814398568 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:37:22 -0500 Subject: [PATCH] test(voice): classify current-frame send and cancellation outcomes Keep viewport and pointer-crop capture unchanged, but expose static reason codes for unavailable frames, invalidated turn captures and retained image sends. No image or user text in diagnostics. --- src/voice/realtimeViewport.js | 16 +++++- src/voice/realtimeViewportPolicy.test.mjs | 61 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 9ab1b07a6..8ba50c73b 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -437,15 +437,24 @@ export class RealtimeViewport { const captured = await captureImage(); const imageUrl = typeof captured === 'string' ? captured : captured?.dataUrl || null; + if (!imageUrl) { + this.debugLog?.('viewport_context.skipped', { + reason: 'capture-unavailable', + }); + return false; + } if ( - !imageUrl || generation !== this.generation || request !== this.captureSeq || this.dc !== channel || channel.readyState !== 'open' || (typeof frame.valid === 'function' && !frame.valid()) - ) + ) { + this.debugLog?.('viewport_context.skipped', { + reason: 'capture-invalidated', + }); return false; + } // Keep at most one viewport screenshot in context. Images are the single // most expensive item (re-billed every turn they linger), so we proactively @@ -506,6 +515,9 @@ export class RealtimeViewport { cameraKey: this.readCameraKey(), } : null; + this.debugLog?.('viewport_context.result', { + reason: sent ? 'sent' : 'data-channel-send-failed', + }); return sent; } diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs index 5a361bcb5..3398f2a09 100644 --- a/src/voice/realtimeViewportPolicy.test.mjs +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -179,3 +179,64 @@ test('closed channel never captures pointer or full viewport', async () => { ); assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); }); + +test('capture failures and invalidated turns are reason-coded without sending', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + operations: { + sendRealtimeEvent: () => { + throw new Error('should not send unavailable image'); + }, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => null, + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'capture-unavailable', + ), + ); + let release; + viewport.capture = () => + new Promise((resolve) => { + release = resolve; + }); + const pending = viewport.sendVisualContextIfUseful(localContext()); + viewport.reset(); + release('data:image/jpeg;base64,AA=='); + assert.equal(await pending, false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'capture-invalidated', + ), + ); +}); + +test('successful retained send is logged without recording image contents', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + readCameraKey: () => 'camera:fixture', + operations: { + sendRealtimeEvent: () => true, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => 'data:image/jpeg;base64,AA==', + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); + assert.ok(diagnostics.some((item) => + item.event === 'viewport_context.result' && item.detail.reason === 'sent' + )); + assert.equal( + JSON.stringify(diagnostics).includes('data:image/jpeg'), + false, + ); +});