Skip to content
Open
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
109 changes: 109 additions & 0 deletions container/agent-runner/src/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,115 @@ describe('XML escaping', () => {
});
});

describe('attachments rendering', () => {
it('renders a plain attachment with localPath', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'here is a doc',
attachments: [{ type: 'document', name: 'spec.pdf', localPath: 'inbox/m1/spec.pdf' }],
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('[document: spec.pdf — saved to /workspace/inbox/m1/spec.pdf]');
});

it('renders inline transcription when a voice attachment has it', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: '',
attachments: [
{
type: 'voice',
name: 'voice.ogg',
localPath: 'inbox/m1/voice.ogg',
mimeType: 'audio/ogg',
transcription: 'Hello, can you check the deploy?',
},
],
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('[voice: voice.ogg');
expect(result).toContain('Transcription: Hello, can you check the deploy?');
});

it('renders transcription error message when whisper failed', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: '',
attachments: [
{
type: 'voice',
name: 'voice.ogg',
mimeType: 'audio/ogg',
localPath: 'inbox/m1/voice.ogg',
transcriptionError: 'OPENAI_API_KEY not set',
},
],
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('Transcription failed: OPENAI_API_KEY not set');
});

it('renders extracted PDF text inside a CDATA-wrapped <pdf_text> block', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'see the PDF',
attachments: [
{
type: 'document',
name: 'spec.pdf',
mimeType: 'application/pdf',
localPath: 'inbox/m1/spec.pdf',
extractedText: 'Chapter 1\nIntroduction\n\nThis document describes the system.',
},
],
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('[pdf: spec.pdf');
expect(result).toContain('<pdf_text><![CDATA[Chapter 1');
expect(result).toContain('This document describes the system.');
expect(result).toContain(']]></pdf_text>');
});

it('escapes embedded "]]>" sequences in PDF text so CDATA stays well-formed', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: '',
attachments: [
{
type: 'document',
name: 'evil.pdf',
mimeType: 'application/pdf',
localPath: 'inbox/m1/evil.pdf',
extractedText: 'before ]]> after',
},
],
});
const result = formatMessages(getPendingMessages());
// The literal "]]>" inside the CDATA body must be neutralised; the
// closing CDATA terminator at the end of the block is still present.
expect(result).toContain('before ]]&gt; after');
expect(result).toContain(']]></pdf_text>');
});

it('renders PDF extraction error inline when extraction failed', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: '',
attachments: [
{
type: 'document',
name: 'spec.pdf',
mimeType: 'application/pdf',
localPath: 'inbox/m1/spec.pdf',
pdfExtractionError: 'pdftotext not installed',
},
],
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('PDF extraction failed: pdftotext not installed');
});
});

describe('stripInternalTags', () => {
it('strips single-line internal tags and trims', () => {
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
Expand Down
30 changes: 30 additions & 0 deletions container/agent-runner/src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,36 @@ function formatAttachments(attachments: any[] | undefined): string {
const type = a.type || 'file';
const localPath = a.localPath ? `/workspace/${a.localPath}` : '';
const url = a.url || '';

// Voice attachments: prefer the host-preprocessed Whisper transcription
// when present. Renders inline so the agent reads the text directly
// rather than having to Read+decode the audio file (no whisper in the
// container). On transcription failure, `a.transcriptionError` carries
// the reason so the agent can surface it instead of staying silent.
if (typeof a.transcription === 'string' && a.transcription.length > 0) {
const head = `[voice: ${escapeXml(name)}` + (localPath ? ` — saved to ${escapeXml(localPath)}]` : `]`);
return `${head}\nTranscription: ${escapeXml(a.transcription)}`;
}
if (typeof a.transcriptionError === 'string' && a.transcriptionError.length > 0) {
const head = `[voice: ${escapeXml(name)}` + (localPath ? ` — saved to ${escapeXml(localPath)}]` : `]`);
return `${head}\nTranscription failed: ${escapeXml(a.transcriptionError)}`;
}

// PDF attachments: prefer the host-preprocessed extracted text when
// present (host runs `pdftotext` on the spilled file). Keeps the path
// available too so the agent can fetch raw bytes if it needs to.
if (typeof a.extractedText === 'string' && a.extractedText.length > 0) {
const head = `[pdf: ${escapeXml(name)}` + (localPath ? ` — saved to ${escapeXml(localPath)}]` : `]`);
// Don't escapeXml the body — agents read it as the literal text;
// host already constrained it to UTF-8 text from pdftotext.
const body = a.extractedText.replace(/]]>/g, ']]&gt;');
return `${head}\n<pdf_text><![CDATA[${body}]]></pdf_text>`;
}
if (typeof a.pdfExtractionError === 'string' && a.pdfExtractionError.length > 0) {
const head = `[pdf: ${escapeXml(name)}` + (localPath ? ` — saved to ${escapeXml(localPath)}]` : `]`);
return `${head}\nPDF extraction failed: ${escapeXml(a.pdfExtractionError)}`;
}

if (localPath) {
return `[${type}: ${escapeXml(name)} — saved to ${escapeXml(localPath)}]`;
}
Expand Down
118 changes: 117 additions & 1 deletion container/agent-runner/src/mcp-tools/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
import { queryReactions, sendMessage } from './core.js';

beforeEach(() => {
initTestSessionDb();
Expand Down Expand Up @@ -48,3 +48,119 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
expect(out[0].in_reply_to).toBeNull();
});
});

function insertReactionRow(
id: string,
timestamp: string,
reaction: { emoji: string; rawEmoji: string; added: boolean; targetMessageId: string; userId: string },
sender: string = 'John',
): void {
const content = JSON.stringify({
text: `[${sender} reacted ${reaction.emoji} on message ${reaction.targetMessageId}]`,
sender,
senderId: reaction.userId,
reaction,
});
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES (?, 'chat-sdk', ?, 'pending', ?)`,
)
.run(id, timestamp, content);
}

describe('query_reactions MCP tool', () => {
it('returns "no reactions" when the session has none', async () => {
const result = await queryReactions.handler({});
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain('No reactions');
});

it('lists all reactions in the session when no filter is given', async () => {
insertReactionRow('rxn-1', '2026-05-22T10:00:00Z', {
emoji: '👍',
rawEmoji: '+1',
added: true,
targetMessageId: 'ts-1',
userId: 'U1',
});
insertReactionRow('rxn-2', '2026-05-22T11:00:00Z', {
emoji: '❤️',
rawEmoji: 'heart',
added: true,
targetMessageId: 'ts-2',
userId: 'U2',
});
const result = await queryReactions.handler({});
const text = result.content[0].text as string;
expect(text).toContain('"count": 2');
expect(text).toContain('"emoji": "👍"');
expect(text).toContain('"emoji": "❤️"');
});

it('filters by target_message_id', async () => {
insertReactionRow('rxn-1', '2026-05-22T10:00:00Z', {
emoji: '👍',
rawEmoji: '+1',
added: true,
targetMessageId: 'ts-A',
userId: 'U1',
});
insertReactionRow('rxn-2', '2026-05-22T11:00:00Z', {
emoji: '❤️',
rawEmoji: 'heart',
added: true,
targetMessageId: 'ts-B',
userId: 'U2',
});
const result = await queryReactions.handler({ target_message_id: 'ts-A' });
const text = result.content[0].text as string;
expect(text).toContain('"count": 1');
expect(text).toContain('ts-A');
expect(text).not.toContain('ts-B');
});

it('preserves added=false (reaction removals)', async () => {
insertReactionRow('rxn-1', '2026-05-22T10:00:00Z', {
emoji: '👍',
rawEmoji: '+1',
added: false,
targetMessageId: 'ts-1',
userId: 'U1',
});
const result = await queryReactions.handler({});
const text = result.content[0].text as string;
expect(text).toContain('"added": false');
});

it('honors limit (orders newest-first)', async () => {
for (let i = 0; i < 5; i++) {
const ts = `2026-05-22T10:0${i}:00Z`;
insertReactionRow(`rxn-${i}`, ts, {
emoji: '⭐',
rawEmoji: 'star',
added: true,
targetMessageId: `ts-${i}`,
userId: 'U1',
});
}
const result = await queryReactions.handler({ limit: 2 });
const text = result.content[0].text as string;
expect(text).toContain('"count": 2');
// newest first: ts-4 and ts-3
expect(text).toContain('ts-4');
expect(text).toContain('ts-3');
expect(text).not.toContain('ts-0');
});

it('ignores non-reaction chat-sdk rows', async () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES (?, 'chat-sdk', ?, 'pending', ?)`,
)
.run('plain-1', '2026-05-22T09:00:00Z', JSON.stringify({ text: 'hi', sender: 'Alice' }));
const result = await queryReactions.handler({});
expect(result.content[0].text).toContain('No reactions');
});
});
Loading
Loading