Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions backend/__tests__/unit/services/nativeRuntimePrompt.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 53 additions & 7 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -476,21 +482,61 @@ 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 {
const payload = (trigger.payload && typeof trigger.payload === 'object'
? trigger.payload as Record<string, any>
: {}) as Record<string, any>;

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

Expand Down
Loading