From 6018db0dd3fb574b82a3637a49c8356a669f358f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 19:23:50 -0800 Subject: [PATCH] feat(companion): add platform stream passthrough helpers --- README.md | 1 + docs/plans/state.json | 13 ++++++++ src/companion/platformClients.test.ts | 22 ++++++++++++++ src/companion/platformClients.ts | 44 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/README.md b/README.md index 2cbebb2..5ecf1b8 100644 --- a/README.md +++ b/README.md @@ -1200,6 +1200,7 @@ Companion runtime helper: - `createHeartbeatLoop()` convenience helper that returns a bound `CompanionHeartbeatLoop` - optional `defaultSessionId` for canvas helper calls so `sessionId` can be omitted per call - `dispose()` lifecycle helper for unified runtime teardown + - stream passthrough helpers (`subscribeAgentStream()`, `waitForAgentStream()`) - `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load, `tickNow()` for manual sends, success/error hooks, failure observability (`failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 56543a6..c9981a0 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -554,6 +554,19 @@ ], "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-platform-stream-helper-passthrough": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added platform-wrapper passthrough helpers for agent stream flows (`subscribeAgentStream`, `waitForAgentStream`) so companion apps can stay on platform clients instead of dropping to raw runtime APIs.", + "files_modified": [ + "src/companion/platformClients.ts", + "src/companion/platformClients.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/platformClients.test.ts b/src/companion/platformClients.test.ts index f1cc913..8e5f8ab 100644 --- a/src/companion/platformClients.test.ts +++ b/src/companion/platformClients.test.ts @@ -25,6 +25,8 @@ function createRuntimeMock(): { listCanvasArtifacts: ReturnType; deleteCanvasArtifact: ReturnType; clearCanvasArtifacts: ReturnType; + subscribeAgentStream: ReturnType; + waitForAgentStream: ReturnType; } { const connect = vi.fn(async () => undefined); const disconnect = vi.fn(() => undefined); @@ -50,6 +52,8 @@ function createRuntimeMock(): { const listCanvasArtifacts = vi.fn(async () => ({ artifacts: [{ id: 'a1' }] })); const deleteCanvasArtifact = vi.fn(async () => ({ deleted: true })); const clearCanvasArtifacts = vi.fn(async () => ({ cleared: 1 })); + const subscribeAgentStream = vi.fn(() => () => undefined); + const waitForAgentStream = vi.fn(async () => ({ token: 'streamed' })); const runtime = { connect, @@ -69,6 +73,8 @@ function createRuntimeMock(): { listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, + subscribeAgentStream, + waitForAgentStream, } as unknown as CompanionRuntimeClient; return { @@ -90,6 +96,8 @@ function createRuntimeMock(): { listCanvasArtifacts, deleteCanvasArtifact, clearCanvasArtifacts, + subscribeAgentStream, + waitForAgentStream, }; } @@ -150,6 +158,20 @@ describe('platform companion clients', () => { expect(mock.dispose).toHaveBeenCalledOnce(); }); + it('platform stream helper methods forward to runtime client', async () => { + const mock = createRuntimeMock(); + const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); + const streamHandler = vi.fn(); + + const unsubscribe = client.subscribeAgentStream(streamHandler); + const awaited = await client.waitForAgentStream<{ token: string }>({ timeoutMs: 500 }); + + expect(mock.subscribeAgentStream).toHaveBeenCalledWith(streamHandler); + expect(mock.waitForAgentStream).toHaveBeenCalledWith({ timeoutMs: 500 }); + expect(awaited).toEqual({ token: 'streamed' }); + unsubscribe(); + }); + it('macOS client forwards canvas methods to runtime client', async () => { const mock = createRuntimeMock(); const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); diff --git a/src/companion/platformClients.ts b/src/companion/platformClients.ts index 9223378..0ebb515 100644 --- a/src/companion/platformClients.ts +++ b/src/companion/platformClients.ts @@ -4,6 +4,8 @@ import type { CanvasGetResult, CanvasListResult, CanvasPutResult, + CompanionEventPredicate, + CompanionTypedEventHandler, CompanionRuntimeClient, DeleteCanvasArtifactInput, GetCanvasArtifactInput, @@ -217,6 +219,20 @@ export class MacOSCompanionClient { return this.runtime.clearCanvasArtifacts(this.resolveSessionId(sessionId)); } + subscribeAgentStream( + handler: CompanionTypedEventHandler, + ): () => void { + return this.runtime.subscribeAgentStream(handler); + } + + waitForAgentStream(options?: { + timeoutMs?: number; + predicate?: CompanionEventPredicate; + signal?: AbortSignal; + }): Promise { + return this.runtime.waitForAgentStream(options); + } + private resolveSessionId(sessionId?: string): string { const resolved = sessionId ?? this.defaultSessionId; if (!resolved) { @@ -368,6 +384,20 @@ export class IOSCompanionClient { return this.runtime.clearCanvasArtifacts(this.resolveSessionId(sessionId)); } + subscribeAgentStream( + handler: CompanionTypedEventHandler, + ): () => void { + return this.runtime.subscribeAgentStream(handler); + } + + waitForAgentStream(options?: { + timeoutMs?: number; + predicate?: CompanionEventPredicate; + signal?: AbortSignal; + }): Promise { + return this.runtime.waitForAgentStream(options); + } + private resolveSessionId(sessionId?: string): string { const resolved = sessionId ?? this.defaultSessionId; if (!resolved) { @@ -517,6 +547,20 @@ export class AndroidCompanionClient { return this.runtime.clearCanvasArtifacts(this.resolveSessionId(sessionId)); } + subscribeAgentStream( + handler: CompanionTypedEventHandler, + ): () => void { + return this.runtime.subscribeAgentStream(handler); + } + + waitForAgentStream(options?: { + timeoutMs?: number; + predicate?: CompanionEventPredicate; + signal?: AbortSignal; + }): Promise { + return this.runtime.waitForAgentStream(options); + } + private resolveSessionId(sessionId?: string): string { const resolved = sessionId ?? this.defaultSessionId; if (!resolved) {