From 54342bfc0e8a9f0d9d617278897668c24c16181c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:32:40 -0700 Subject: [PATCH] fix(native): the runtime discarded the server-composed cue (#1071, TASK-034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native tier ran on board wakes and ran blind. Routing is type-agnostic (agentEventService:944 gates on runtimeType only), so the event arrives — but buildUserMessage branched on 'mention'/'heartbeat'/'first_contact' and fell through to a generic "Trigger: X, use commonly_read_context" that threw away payload.content, which for a board wake is the entire task list. Wider than board wakes: trigger.type is the RAW event type, forwarded verbatim at agentEventService:994 — the claimable-set gate at :697 in this same file says so. So 'chat.mention' never equalled 'mention' and that branch had never fired either. Of 58 events ever delivered to the 17 active native installs, 24 message.posted + 6 chat.mention = 30 took the fallthrough. Only heartbeat and first_contact matched. Fix is producer parity with the wrapper: prefer payload.content, then prompt, then text — exactly what extractPrompt does — before any type branch. Type branches remain as fallbacks for content-less events, now matching the raw names the caller actually sends. Also widens NativeRunTrigger.type to string. The union declared a vocabulary the caller never speaks ('task.assigned', 'pod.join') and omitted the types it does; require() at the call site meant TypeScript never reconciled them, which is how a branch matching nothing survived. 12 behaviour tests driving the real function, not source assertions. Mutations each red: cue passthrough removed (8), raw mention types removed (1), wrapper-parity fallbacks dropped (2). Co-Authored-By: Claude Opus 5 --- .../unit/services/nativeRuntimePrompt.test.js | 73 +++++++++++++++++++ backend/services/nativeRuntimeService.ts | 60 +++++++++++++-- 2 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/unit/services/nativeRuntimePrompt.test.js diff --git a/backend/__tests__/unit/services/nativeRuntimePrompt.test.js b/backend/__tests__/unit/services/nativeRuntimePrompt.test.js new file mode 100644 index 0000000000..29e86d0c2c --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimePrompt.test.js @@ -0,0 +1,73 @@ +/** + * The native runtime must use the server-composed cue (#1071, TASK-034). + * + * `payload.content` is where every inline cue lives — the pod-context frame, + * the ADR-012 §9 DM frame, the board-wake task list, the lease warning — + * because metadata gets deprioritised by the model. The wrapper's + * `extractPrompt` has always preferred it; this tier did not, and the gap was + * invisible because both tiers still "ran". + * + * These drive the real function rather than asserting on source text. The + * regression they pin is that `trigger.type` is the RAW event type, so + * `chat.mention` never matched a branch testing for `'mention'` and every + * message-shaped wake reached the model with its cue discarded. + */ +const { buildUserMessage } = require('../../../services/nativeRuntimeService'); + +const POD = 'Sprint Pod'; +const boardWakeCue = '[The kernel found unclaimed work in this pod — 1 pending, unassigned:]\n\n- TASK-034 — Native runtime runs on board wakes'; + +describe('the inline cue reaches the model verbatim', () => { + it.each([ + ['message.posted (board wake)', 'message.posted'], + ['chat.mention', 'chat.mention'], + ['dm.message', 'dm.message'], + ['thread.mention', 'thread.mention'], + ['an unknown future type', 'some.new.type'], + ])('%s — content is the prompt, unmodified', (_label, type) => { + const out = buildUserMessage({ type, payload: { content: boardWakeCue } }, POD); + expect(out).toBe(boardWakeCue); + }); + + it('the board-wake task list survives — the whole point of TASK-034', () => { + const out = buildUserMessage({ type: 'message.posted', payload: { content: boardWakeCue } }, POD); + expect(out).toContain('TASK-034'); + expect(out).toContain('1 pending, unassigned'); + // The pre-fix fallthrough replaced all of this with a generic instruction. + expect(out).not.toContain('Use commonly_read_context to understand'); + }); + + it.each(['prompt', 'text'])('falls back to payload.%s like the wrapper does', (key) => { + const out = buildUserMessage({ type: 'message.posted', payload: { [key]: 'do the thing' } }, POD); + expect(out).toBe('do the thing'); + }); +}); + +describe('content-less events still get something usable', () => { + it('a mention with no content names the message instead of going generic', () => { + const out = buildUserMessage( + { type: 'chat.mention', payload: { username: 'sam', messageId: '56527' } }, + POD, + ); + expect(out).toContain('sam'); + expect(out).toContain('56527'); + expect(out).toContain(POD); + }); + + it('heartbeat keeps its own prompt when no cue is supplied', () => { + const out = buildUserMessage({ type: 'heartbeat', payload: {} }, POD); + expect(out).toContain('heartbeat'); + expect(out).toContain(POD); + }); + + it('whitespace-only content is not a cue', () => { + const out = buildUserMessage({ type: 'heartbeat', payload: { content: ' \n ' } }, POD); + expect(out).toContain('heartbeat'); + }); + + it('a genuinely unknown, content-less type still returns a usable instruction', () => { + const out = buildUserMessage({ type: 'pod.join', payload: {} }, POD); + expect(out).toContain('pod.join'); + expect(out.length).toBeGreaterThan(20); + }); +}); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index a8dd121982..7f0c37f1d8 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -30,7 +30,13 @@ import axios, { AxiosError } from 'axios'; // --- public surface -------------------------------------------------------- export interface NativeRunTrigger { - type: 'mention' | 'heartbeat' | 'task.assigned' | 'chat.message' | 'pod.join' | 'first_contact' | 'manual'; + // The RAW event type, forwarded verbatim by agentEventService:994 — not a + // mapped vocabulary. The previous union listed names the caller never sends + // ('mention', 'chat.message', 'task.assigned', 'pod.join') and omitted the + // ones it does ('chat.mention', 'message.posted', 'dm.message'), which is + // how a branch that never fired went unnoticed. `string` is honest here; + // narrowing it again means narrowing it to what the producer emits. + type: string; eventId?: string; payload?: unknown; } @@ -476,7 +482,15 @@ function buildSystemPrompt(installation: any, cfg: PlainConfig): string { ); } -function buildUserMessage( +// The mention-shaped raw event types the caller actually sends, plus the +// legacy union spellings. Deliberately a superset of what matches today: a +// name in here that never arrives costs nothing, a name missing from it costs +// a discarded cue, and this file has already paid that once. +const MENTION_RAW_TYPES = new Set([ + 'chat.mention', 'thread.mention', 'dm.message', 'mention', 'chat.message', +]); + +export function buildUserMessage( trigger: NativeRunTrigger, podName: string, ): string { @@ -484,13 +498,45 @@ function buildUserMessage( ? trigger.payload as Record : {}) as Record; - if (trigger.type === 'mention') { + // Producer parity with the wrapper (#1071, TASK-034). `payload.content` is + // the server-composed cue and it is ALWAYS the best prompt when present: + // the pod-context frame, the ADR-012 §9 DM frame, the board-wake task list + // and the lease warning are all written inline there precisely because + // metadata gets deprioritised by the model. The wrapper's `extractPrompt` + // has always preferred it; this tier did not, and the gap was invisible + // because both tiers "ran". + // + // WHAT WAS BROKEN. The branches below compare `trigger.type` against + // 'mention' / 'chat.message', but the caller passes the RAW event type — + // agentEventService:994 forwards `type` verbatim, and the claimable-set gate + // at :697 in this same file says so out loud ("raw-type gate mirrors the + // wrapper's claimable set"). So `chat.mention` never matched 'mention', and + // every message-shaped wake fell through to the generic "Trigger: X, use + // commonly_read_context" below — which discards the cue entirely and tells + // the agent to go look around instead. + // + // Measured before the fix: of 58 events ever delivered to the 17 active + // native installs, 24 `message.posted` + 6 `chat.mention` = 30 took the + // fallthrough. Only `heartbeat` and `first_contact` ever matched a branch. + // + // The `NativeRunTrigger` union at the top of this file declares a vocabulary + // ('mention', 'chat.message', 'task.assigned', 'pod.join') that the caller + // never speaks, and omits the types it does. `require()` at the call site + // meant TypeScript never had to reconcile them. + const inlineCue = String(payload.content || payload.prompt || payload.text || '').trim(); + if (inlineCue) return inlineCue; + + // Fallbacks below are for events that carry NO content. They keep the raw + // type names the caller actually sends, plus the legacy union names, so a + // future mapped caller still lands somewhere sensible. + if (MENTION_RAW_TYPES.has(String(trigger.type))) { const user = String(payload.username || payload.userId || 'someone'); - const text = String(payload.content || payload.text || '').trim(); + const messageId = String(payload.messageId || ''); return ( - `User @${user} mentioned you in pod ${podName}: "${text}". ` - + 'Call commonly_read_context if you need more history, then call ' - + 'commonly_post_message with your reply.' + `You were mentioned in pod ${podName} by @${user}, but the event carried no ` + + 'content — a producer bug, not your fault. ' + + (messageId ? `It named message ${messageId}. ` : '') + + 'Call commonly_read_context to find it, then commonly_post_message with your reply.' ); }