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.' ); }