test(extractText): bootstrap bun:test + pin v0/v1 part discriminator
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) <noreply@anthropic.com>
This commit is contained in:
parent
69a21a0ea9
commit
68d8ae981f
27
.github/workflows/test.yml
vendored
Normal file
27
.github/workflows/test.yml
vendored
Normal file
@ -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
|
||||||
134
extract-text.test.ts
Normal file
134
extract-text.test.ts
Normal file
@ -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> = {}): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
69
extract-text.ts
Normal file
69
extract-text.ts
Normal file
@ -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)'
|
||||||
|
}
|
||||||
@ -6,7 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "./server.ts",
|
"bin": "./server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun install --no-summary && bun server.ts"
|
"start": "bun install --no-summary && bun server.ts",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||||
|
|||||||
65
server.ts
65
server.ts
@ -40,6 +40,7 @@ import { z } from 'zod'
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync } from 'fs'
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, renameSync, unlinkSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { extractText, type ActivityEntry } from './extract-text.ts'
|
||||||
|
|
||||||
// ─── Config ─────────────────────────────────────────────────────────────
|
// ─── Config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -171,20 +172,9 @@ process.on('uncaughtException', err => {
|
|||||||
// activity_logs is paged out at 30 days, so an honest seen-id set never
|
// activity_logs is paged out at 30 days, so an honest seen-id set never
|
||||||
// grows unbounded; new sessions start fresh.
|
// grows unbounded; new sessions start fresh.
|
||||||
|
|
||||||
interface ActivityEntry {
|
// ActivityEntry lives in extract-text.ts (imported above) so unit
|
||||||
id: string
|
// tests can import the type + helper without triggering server.ts's
|
||||||
workspace_id: string
|
// boot-time side-effects (cursor load, MCP transport connect).
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cursor persistence ────────────────────────────────────────────────
|
// ─── Cursor persistence ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@ -442,53 +432,6 @@ async function registerAsPoll(workspaceId: string): Promise<void> {
|
|||||||
|
|
||||||
// ─── Notification emission ─────────────────────────────────────────────
|
// ─── 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 {
|
function emitNotification(mcp: Server, workspaceId: string, act: ActivityEntry): void {
|
||||||
const text = extractText(act)
|
const text = extractText(act)
|
||||||
// Discriminate canvas-user messages (typed in the canvas chat panel) from
|
// Discriminate canvas-user messages (typed in the canvas chat panel) from
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user