From 68d8ae981f69eafb09f430f91425b679bf286446 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 20:15:31 -0700 Subject: [PATCH] test(extractText): bootstrap bun:test + pin v0/v1 part discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the extractText helper + ActivityEntry type into their own module so unit tests can import them without dragging server.ts's top-level boot side-effects (cursor load, MCP transport connect, poll loop) into the test runner. server.ts re-imports both — the wire behavior is unchanged. Tests cover the v0/v1 part-discriminator regression that landed on 2026-04-30 (every canvas peer message returning act.summary because parts had `kind` instead of `type`): - v1 `kind: text` parts (current production shape) - v0 `type: text` back-compat - multi-part text join, ignore non-text parts - body-shape priority: params.message.parts > params.parts > body.parts - empty-text-part recovery: skips empty candidate, tries next - summary fallback when no shape matches - `(empty A2A message)` fallback when summary is null Adds: - extract-text.ts (helper + type, no side-effects) - extract-text.test.ts (9 tests) - .github/workflows/test.yml (bun test on push/PR) - "test": "bun test" script in package.json Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 27 ++++++++ extract-text.test.ts | 134 +++++++++++++++++++++++++++++++++++++ extract-text.ts | 69 +++++++++++++++++++ package.json | 3 +- server.ts | 65 ++---------------- 5 files changed, 236 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 extract-text.test.ts create mode 100644 extract-text.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6afd1d7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + bun-test: + name: bun test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run tests + run: bun test diff --git a/extract-text.test.ts b/extract-text.test.ts new file mode 100644 index 0000000..235fa08 --- /dev/null +++ b/extract-text.test.ts @@ -0,0 +1,134 @@ +// Regression tests for extractText. The 2026-04-30 incident — every +// canvas peer message arriving but extractText returning act.summary +// because parts had `kind` instead of `type` — is the failure mode +// these tests pin against. Add new shape coverage here when the +// platform's a2a_proxy logging changes. + +import { describe, expect, it } from 'bun:test' +import { extractText, type ActivityEntry } from './extract-text.ts' + +function act(overrides: Partial = {}): ActivityEntry { + return { + id: 'a-1', + workspace_id: 'w-1', + activity_type: 'a2a_receive', + source_id: 'peer-1', + target_id: 'w-1', + method: 'message/send', + summary: 'fallback summary', + request_body: undefined, + response_body: undefined, + status: 'ok', + error_detail: null, + created_at: '2026-04-30T00:00:00Z', + ...overrides, + } +} + +describe('extractText — part discriminator', () => { + it('accepts a2a-sdk v1 parts (kind: text) — the production shape', () => { + const text = extractText( + act({ + request_body: { + jsonrpc: '2.0', + method: 'message/send', + params: { message: { parts: [{ kind: 'text', text: 'hello v1' }] } }, + }, + }), + ) + expect(text).toBe('hello v1') + }) + + it('accepts legacy v0 parts (type: text) — back-compat', () => { + const text = extractText( + act({ + request_body: { + jsonrpc: '2.0', + method: 'message/send', + params: { message: { parts: [{ type: 'text', text: 'hello v0' }] } }, + }, + }), + ) + expect(text).toBe('hello v0') + }) + + it('joins multiple text parts in order, ignoring non-text parts', () => { + const text = extractText( + act({ + request_body: { + params: { + message: { + parts: [ + { kind: 'text', text: 'one ' }, + { kind: 'data', text: 'should-skip' }, + { kind: 'text', text: 'two' }, + ], + }, + }, + }, + }), + ) + expect(text).toBe('one two') + }) +}) + +describe('extractText — body shape priority', () => { + it('prefers params.message.parts (canonical JSON-RPC envelope)', () => { + const text = extractText( + act({ + request_body: { + params: { + message: { parts: [{ kind: 'text', text: 'shape-1' }] }, + parts: [{ kind: 'text', text: 'shape-2' }], + }, + parts: [{ kind: 'text', text: 'shape-3' }], + }, + }), + ) + expect(text).toBe('shape-1') + }) + + it('falls back to params.parts when message wrapper is absent', () => { + const text = extractText( + act({ + request_body: { + params: { parts: [{ kind: 'text', text: 'shape-2' }] }, + }, + }), + ) + expect(text).toBe('shape-2') + }) + + it('falls back to body.parts (canvas-side direct sends)', () => { + const text = extractText( + act({ request_body: { parts: [{ kind: 'text', text: 'shape-3' }] } }), + ) + expect(text).toBe('shape-3') + }) +}) + +describe('extractText — fallbacks', () => { + it('returns act.summary when no shape matches', () => { + const text = extractText( + act({ request_body: { unrelated: 'envelope' }, summary: 'audit summary' }), + ) + expect(text).toBe('audit summary') + }) + + it('returns the empty-marker when summary is null and body has no parts', () => { + const text = extractText(act({ request_body: undefined, summary: null })) + expect(text).toBe('(empty A2A message)') + }) + + it('skips empty-text parts and tries the next candidate before falling back', () => { + const text = extractText( + act({ + request_body: { + params: { message: { parts: [{ kind: 'text', text: '' }] } }, + parts: [{ kind: 'text', text: 'recovered' }], + }, + }), + ) + expect(text).toBe('recovered') + }) +}) diff --git a/extract-text.ts b/extract-text.ts new file mode 100644 index 0000000..9b4a8ba --- /dev/null +++ b/extract-text.ts @@ -0,0 +1,69 @@ +// extractText — pull human-readable text out of a platform activity row's +// request_body. Lives in its own module so the unit test can import it +// without triggering server.ts's top-level boot side-effects (cursor +// load, MCP transport connect, poll loop). +// +// Shape & semantics: see the call site in server.ts and the +// long-form comment there. This file just owns the function. + +export interface ActivityEntry { + id: string + workspace_id: string + activity_type: string + source_id: string | null + target_id: string | null + method: string | null + summary: string | null + request_body?: unknown + response_body?: unknown + status: string + error_detail: string | null + created_at: string +} + +export function extractText(act: ActivityEntry): string { + // request_body is what the platform's a2a_proxy logs when forwarding A2A + // to this workspace. Empirically (verified against workspace-server's + // logA2ASuccess in a2a_proxy_helpers.go on 2026-04-29), the shape varies: + // + // 1. JSON-RPC envelope (most common — what real peers send): + // { jsonrpc, id, method: "message/send", params: { message: { parts: [...] } } } + // 2. JSON-RPC with params.parts directly (some legacy callers): + // { jsonrpc, id, method, params: { parts: [...] } } + // 3. Shorthand body (canvas-side direct sends): + // { parts: [...] } + // + // Walk the envelope in priority order. Fall back to act.summary so the peer + // message at least surfaces SOMETHING — silent-drop is the failure mode this + // helper exists to prevent. + // Part discriminator: a2a-sdk v0 used `type`, v1 (current) uses + // `kind`. Real platform peers send `kind === 'text'`, so dropping + // v1-shaped parts silently masks every inbound message. Accept both + // — see workspace/inbox.py:_extract_text for the same v0/v1 fix on + // the universal-MCP path. Reproduced live on hongmingwang tenant + // 2026-04-30: messages from canvas peers were arriving but extractText + // returned only act.summary because every part had `kind` not `type`. + const body = act.request_body as { + parts?: Array<{ type?: string; kind?: string; text?: string }> + params?: { + message?: { parts?: Array<{ type?: string; kind?: string; text?: string }> } + parts?: Array<{ type?: string; kind?: string; text?: string }> + } + } | undefined + + const candidates = [ + body?.params?.message?.parts, // shape 1 — JSON-RPC w/ message wrapper + body?.params?.parts, // shape 2 — JSON-RPC params.parts + body?.parts, // shape 3 — shorthand + ] + for (const parts of candidates) { + if (Array.isArray(parts)) { + const text = parts + .filter(p => p.kind === 'text' || p.type === 'text') + .map(p => p.text ?? '') + .join('') + if (text) return text + } + } + return act.summary ?? '(empty A2A message)' +} diff --git a/package.json b/package.json index 729893c..a9edb5d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "module", "bin": "./server.ts", "scripts": { - "start": "bun install --no-summary && bun server.ts" + "start": "bun install --no-summary && bun server.ts", + "test": "bun test" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" diff --git a/server.ts b/server.ts index b0ffc73..6216bff 100644 --- a/server.ts +++ b/server.ts @@ -40,6 +40,7 @@ import { z } from 'zod' import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +import { extractText, type ActivityEntry } from './extract-text.ts' // ─── Config ───────────────────────────────────────────────────────────── @@ -171,20 +172,9 @@ process.on('uncaughtException', err => { // activity_logs is paged out at 30 days, so an honest seen-id set never // grows unbounded; new sessions start fresh. -interface ActivityEntry { - id: string - workspace_id: string - activity_type: string - source_id: string | null - target_id: string | null - method: string | null - summary: string | null - request_body?: unknown - response_body?: unknown - status: string - error_detail: string | null - created_at: string -} +// ActivityEntry lives in extract-text.ts (imported above) so unit +// tests can import the type + helper without triggering server.ts's +// boot-time side-effects (cursor load, MCP transport connect). // ─── Cursor persistence ──────────────────────────────────────────────── // @@ -442,53 +432,6 @@ async function registerAsPoll(workspaceId: string): Promise { // ─── Notification emission ───────────────────────────────────────────── -function extractText(act: ActivityEntry): string { - // request_body is what the platform's a2a_proxy logs when forwarding A2A - // to this workspace. Empirically (verified against workspace-server's - // logA2ASuccess in a2a_proxy_helpers.go on 2026-04-29), the shape varies: - // - // 1. JSON-RPC envelope (most common — what real peers send): - // { jsonrpc, id, method: "message/send", params: { message: { parts: [...] } } } - // 2. JSON-RPC with params.parts directly (some legacy callers): - // { jsonrpc, id, method, params: { parts: [...] } } - // 3. Shorthand body (canvas-side direct sends): - // { parts: [...] } - // - // Walk the envelope in priority order. Fall back to act.summary so the peer - // message at least surfaces SOMETHING — silent-drop is the failure mode this - // helper exists to prevent. - // Part discriminator: a2a-sdk v0 used `type`, v1 (current) uses - // `kind`. Real platform peers send `kind === 'text'`, so dropping - // v1-shaped parts silently masks every inbound message. Accept both - // — see workspace/inbox.py:_extract_text for the same v0/v1 fix on - // the universal-MCP path. Reproduced live on hongmingwang tenant - // 2026-04-30: messages from canvas peers were arriving but extractText - // returned only act.summary because every part had `kind` not `type`. - const body = act.request_body as { - parts?: Array<{ type?: string; kind?: string; text?: string }> - params?: { - message?: { parts?: Array<{ type?: string; kind?: string; text?: string }> } - parts?: Array<{ type?: string; kind?: string; text?: string }> - } - } | undefined - - const candidates = [ - body?.params?.message?.parts, // shape 1 — JSON-RPC w/ message wrapper - body?.params?.parts, // shape 2 — JSON-RPC params.parts - body?.parts, // shape 3 — shorthand - ] - for (const parts of candidates) { - if (Array.isArray(parts)) { - const text = parts - .filter(p => p.kind === 'text' || p.type === 'text') - .map(p => p.text ?? '') - .join('') - if (text) return text - } - } - return act.summary ?? '(empty A2A message)' -} - function emitNotification(mcp: Server, workspaceId: string, act: ActivityEntry): void { const text = extractText(act) // Discriminate canvas-user messages (typed in the canvas chat panel) from