Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/soft-cobras-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agents-api': patch
---

Add inline text document attachments to the run chat APIs for `text/plain`, `text/markdown`, `text/html`, `text/csv`, `text/x-log`, and `application/json` while keeping remote URLs limited to PDFs. Persist text attachments as blob-backed file parts and replay them into model input as XML-tagged text blocks.
4 changes: 4 additions & 0 deletions agents-api/__snapshots__/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -41181,6 +41181,10 @@
{
"format": "uri",
"type": "string"
},
{
"pattern": "^data:(text\\/(plain|markdown|html|csv|x-log)|application\\/json);base64,",
"type": "string"
}
]
},
Expand Down
132 changes: 132 additions & 0 deletions agents-api/src/__tests__/run/agents/conversation-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest';

const { downloadMock } = vi.hoisted(() => ({ downloadMock: vi.fn() }));

vi.mock('../../../../logger', () => ({
getLogger: () => ({ warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }),
}));

vi.mock('../../../domains/run/services/blob-storage', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../domains/run/services/blob-storage')>();
return { ...actual, getBlobStorageProvider: () => ({ download: downloadMock }) };
});

import { buildUserMessageContent } from '../../../domains/run/agents/generation/conversation-history';

describe('buildUserMessageContent', () => {
it('injects inline text attachments as XML attachment blocks', async () => {
const content = await buildUserMessageContent('Please summarize this', [
{
kind: 'file',
file: {
bytes: Buffer.from('# Title\r\n\r\nHello world', 'utf8').toString('base64'),
mimeType: 'text/markdown',
},
metadata: {
filename: 'notes.md',
},
},
]);

expect(content).toEqual([
{ type: 'text', text: 'Please summarize this' },
{
type: 'text',
text: [
'<attached_file filename="notes.md" media_type="text/markdown">',
'# Title',
'',
'Hello world',
'</attached_file>',
].join('\n'),
},
]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Missing test for blob-backed text attachment replay

Issue: This test only covers the inline bytes path (file.bytes present). The blob URI download path (file.uri with blob:// scheme) at lines 134-136 of conversation-history.ts has no test coverage.

Why: If the blob download integration is broken (wrong URI parsing, missing await, mock not configured), text attachments will fail during conversation history replay. Users would see errors like "Blob not found" on subsequent turns after uploading text files.

Fix: Add a test for blob-backed attachments:

it('downloads and injects blob-backed text attachments', async () => {
  vi.spyOn(getBlobStorageProvider(), 'download').mockResolvedValue({
    data: Buffer.from('# Title\n\nHello from blob', 'utf8'),
    mimeType: 'text/markdown',
  });

  const content = await buildUserMessageContent('Summarize this', [
    {
      kind: 'file',
      file: {
        uri: 'blob://v1/t_tenant/media/p_proj/conv/c_conv/m_msg/sha256-abc123',
        mimeType: 'text/markdown',
      },
      metadata: { filename: 'notes.md' },
    },
  ]);

  expect(getBlobStorageProvider().download).toHaveBeenCalledWith(
    'v1/t_tenant/media/p_proj/conv/c_conv/m_msg/sha256-abc123'
  );
  expect(content[1]).toMatchObject({
    type: 'text',
    text: expect.stringContaining('# Title'),
  });
});

Refs:


it('downloads and injects blob-backed text attachments', async () => {
downloadMock.mockResolvedValue({
data: Buffer.from('# Title\n\nHello from blob', 'utf8'),
mimeType: 'text/markdown',
});

const content = await buildUserMessageContent('Summarize this', [
{
kind: 'file',
file: {
uri: 'blob://v1/t_tenant/media/p_proj/conv/c_conv/m_msg/sha256-abc123',
mimeType: 'text/markdown',
},
metadata: { filename: 'notes.md' },
},
]);

expect(downloadMock).toHaveBeenCalledWith(
'v1/t_tenant/media/p_proj/conv/c_conv/m_msg/sha256-abc123'
);
expect(content).toEqual([
{ type: 'text', text: 'Summarize this' },
{
type: 'text',
text: [
'<attached_file filename="notes.md" media_type="text/markdown">',
'# Title',
'',
'Hello from blob',
'</attached_file>',
].join('\n'),
},
]);
});

it('injects inline JSON attachments as XML attachment blocks', async () => {
const content = await buildUserMessageContent('Summarize this payload', [
{
kind: 'file',
file: {
bytes: Buffer.from('{"items":[1,2,3]}\n', 'utf8').toString('base64'),
mimeType: 'application/json',
},
metadata: {
filename: 'payload.json',
},
},
]);

expect(content).toEqual([
{ type: 'text', text: 'Summarize this payload' },
{
type: 'text',
text: [
'<attached_file filename="payload.json" media_type="application/json">',
'{"items":[1,2,3]}',
'',
'</attached_file>',
].join('\n'),
},
]);
});

it('returns unavailable block when blob download fails', async () => {
downloadMock.mockRejectedValue(new Error('Blob not found'));

const content = await buildUserMessageContent('Summarize this', [
{
kind: 'file',
file: {
uri: 'blob://v1/t_tenant/media/p_proj/conv/c_conv/m_msg/sha256-abc123',
mimeType: 'text/markdown',
},
metadata: { filename: 'notes.md' },
},
]);

expect(content).toEqual([
{ type: 'text', text: 'Summarize this' },
{
type: 'text',
text: expect.stringContaining('[Attachment unavailable]'),
},
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ vi.mock('../../../domains/run/compression/ConversationCompressor', () => ({
ConversationCompressor: vi.fn(),
}));

const mockBlobDownload = vi.fn();

vi.mock('../../../domains/run/services/blob-storage', () => ({
isBlobUri: (value: string) => value.startsWith('blob://'),
fromBlobUri: (value: string) => value.slice('blob://'.length),
getBlobStorageProvider: () => ({
download: mockBlobDownload,
}),
}));

import { getConversationHistory, getLedgerArtifacts } from '@inkeep/agents-core';
import { getConversationHistoryWithCompression } from '../../../domains/run/data/conversations';

Expand Down Expand Up @@ -66,6 +76,7 @@ describe('getConversationHistoryWithCompression — artifact replacement', () =>
beforeEach(() => {
vi.clearAllMocks();
mockGetConversationHistory.mockReturnValue(vi.fn().mockResolvedValue([]));
mockBlobDownload.mockReset();
});

it('replaces tool-result content with compact artifact reference', async () => {
Expand Down
12 changes: 6 additions & 6 deletions agents-api/src/__tests__/run/data/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,30 +141,30 @@ describe('reconstructMessageText', () => {
});

describe('formatMessagesAsConversationHistory', () => {
it('returns empty string when there are no messages', () => {
expect(formatMessagesAsConversationHistory([])).toBe('');
it('returns empty string when there are no messages', async () => {
await expect(formatMessagesAsConversationHistory([])).resolves.toBe('');
});

it('returns empty string when every message has empty reconstructed text', () => {
it('returns empty string when every message has empty reconstructed text', async () => {
const messages = [
{
role: 'user',
messageType: 'chat',
content: { parts: [{ kind: 'image' }] },
},
] as MessageSelect[];
expect(formatMessagesAsConversationHistory(messages)).toBe('');
await expect(formatMessagesAsConversationHistory(messages)).resolves.toBe('');
});

it('wraps non-empty history in conversation_history tags', () => {
it('wraps non-empty history in conversation_history tags', async () => {
const messages = [
{
role: 'user',
messageType: 'chat',
content: { text: 'hi' },
},
] as MessageSelect[];
expect(formatMessagesAsConversationHistory(messages)).toBe(
await expect(formatMessagesAsConversationHistory(messages)).resolves.toBe(
'<conversation_history>\nuser: """hi"""\n</conversation_history>\n'
);
});
Expand Down
171 changes: 171 additions & 0 deletions agents-api/src/__tests__/run/routes/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ import * as execModule from '../../../domains/run/handlers/executionHandler';
import { PdfUrlIngestionError } from '../../../domains/run/services/blob-storage/file-security-errors';
import { makeRequest } from '../../utils/testRequest';

const buildDataUri = (mimeType: string, bytes: Buffer): string => {
return `data:${mimeType};base64,${bytes.toString('base64')}`;
};

const TEXT_DOCUMENT_LIMIT_BYTES = 256 * 1024;

const buildOpenAiTextAttachmentRequest = (options: { fileData: string; filename?: string }) => ({
model: 'claude-3-sonnet',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Summarize this document' },
{
type: 'file',
file: {
file_data: options.fileData,
...(options.filename === undefined ? {} : { filename: options.filename }),
},
},
],
},
],
conversationId: 'conv-123',
});

// Mock context exports used by the chat route (routes/chat.ts imports from ../context)
vi.mock('../../../domains/run/context', () => ({
handleContextResolution: vi.fn().mockResolvedValue({}),
Expand Down Expand Up @@ -301,6 +327,151 @@ describe('Chat Routes', () => {
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should accept inline text document content item in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: 'data:text/plain;base64,aGVsbG8gd29ybGQ=',
filename: 'notes.txt',
})
),
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should accept inline text document content item without filename in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: buildDataUri('text/plain', Buffer.from('hello world', 'utf8')),
})
),
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should accept inline text document content item exactly at the 256 KB limit in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: buildDataUri('text/plain', Buffer.alloc(TEXT_DOCUMENT_LIMIT_BYTES, 0x61)),
filename: 'boundary.txt',
})
),
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should reject malformed base64 text document content item in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: 'data:text/plain;base64,!!!not-base64!!!',
filename: 'bad.txt',
})
),
expectError: true,
});

expect(response.status).toBe(400);
});

it('should reject oversized text document content item in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: buildDataUri('text/plain', Buffer.alloc(TEXT_DOCUMENT_LIMIT_BYTES + 1, 0x61)),
filename: 'too-large.txt',
})
),
expectError: true,
});

expect(response.status).toBe(400);
});

it('should reject binary payload masquerading as text/plain in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(
buildOpenAiTextAttachmentRequest({
fileData: buildDataUri('text/plain', Buffer.from([0x00, 0x9f, 0x92, 0x96, 0xff, 0x00])),
filename: 'binary.txt',
})
),
expectError: true,
});

expect(response.status).toBe(400);
});

it('should accept inline HTML content item in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
model: 'claude-3-sonnet',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Summarize this HTML file' },
{
type: 'file',
file: {
file_data: 'data:text/html;base64,PGgxPkhlbGxvPC9oMT4=',
filename: 'page.html',
},
},
],
},
],
conversationId: 'conv-123',
}),
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should accept inline JSON content item in OpenAI-style messages', async () => {
const response = await makeRequest('/run/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({
model: 'claude-3-sonnet',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Summarize this JSON file' },
{
type: 'file',
file: {
file_data: 'data:application/json;base64,eyJoZWxsbyI6IndvcmxkIn0=',
filename: 'payload.json',
},
},
],
},
],
conversationId: 'conv-123',
}),
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
});

it('should return 400 when PDF URL ingestion fails', async () => {
const { inlineExternalPdfUrlParts } = await import(
'../../../domains/run/services/blob-storage/file-upload-helpers'
Expand Down
Loading
Loading