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`, and `text/x-log` 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 @@ -40428,6 +40428,10 @@
{
"format": "uri",
"type": "string"
},
{
"pattern": "^data:text\\/(plain|markdown|html|csv|x-log);base64,",
"type": "string"
}
]
},
Expand Down
38 changes: 38 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,38 @@
import { describe, expect, it, vi } from 'vitest';

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

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:

});
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
56 changes: 56 additions & 0 deletions agents-api/src/__tests__/run/routes/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,62 @@ 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({
model: 'claude-3-sonnet',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Summarize this document' },
{
type: 'file',
file: {
file_data: 'data:text/plain;base64,aGVsbG8gd29ybGQ=',
filename: 'notes.txt',
},
},
],
},
],
conversationId: 'conv-123',
}),
});

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

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 return 400 when PDF URL ingestion fails', async () => {
const { inlineExternalPdfUrlParts } = await import(
'../../../domains/run/services/blob-storage/file-upload-helpers'
Expand Down
111 changes: 111 additions & 0 deletions agents-api/src/__tests__/run/routes/chat/dataChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,117 @@ describe('Chat Data Stream Route', () => {
expect(res.headers.get('x-vercel-ai-data-stream')).toBe('v2');
});

it('should accept inline text document file part in Vercel messages format', async () => {
const body = {
messages: [
{
role: 'user',
content: 'Summarize this markdown file',
parts: [
{ type: 'text', text: 'Summarize this markdown file' },
{
type: 'file',
url: 'data:text/markdown;base64,IyBUaXRsZQoKLSBpdGVt',
mediaType: 'text/markdown',
filename: 'doc.md',
},
],
},
],
};

const res = await makeRequest('/run/api/chat', {
method: 'POST',
body: JSON.stringify(body),
});

expect(res.status).toBe(200);
expect(res.headers.get('x-vercel-ai-data-stream')).toBe('v2');
});

it('should accept inline CSV file part in Vercel messages format', async () => {
const body = {
messages: [
{
role: 'user',
content: 'Summarize this CSV file',
parts: [
{ type: 'text', text: 'Summarize this CSV file' },
{
type: 'file',
url: 'data:text/csv;base64,bmFtZSxjb3VudAphbHBoYSwxCg==',
mediaType: 'text/csv',
filename: 'data.csv',
},
],
},
],
};

const res = await makeRequest('/run/api/chat', {
method: 'POST',
body: JSON.stringify(body),
});

expect(res.status).toBe(200);
expect(res.headers.get('x-vercel-ai-data-stream')).toBe('v2');
});

it('should accept inline log file part in Vercel messages format', async () => {
const body = {
messages: [
{
role: 'user',
content: 'Summarize this log file',
parts: [
{ type: 'text', text: 'Summarize this log file' },
{
type: 'file',
url: 'data:text/x-log;base64,W2luZm9dIGJvb3QKW2Vycm9yXSBmYWlsdXJlCg==',
mediaType: 'text/x-log',
filename: 'server.log',
},
],
},
],
};

const res = await makeRequest('/run/api/chat', {
method: 'POST',
body: JSON.stringify(body),
});

expect(res.status).toBe(200);
expect(res.headers.get('x-vercel-ai-data-stream')).toBe('v2');
});

it('should reject remote URLs for text document file parts in Vercel messages format', async () => {
const body = {
messages: [
{
role: 'user',
content: 'Summarize this markdown file',
parts: [
{ type: 'text', text: 'Summarize this markdown file' },
{
type: 'file',
url: 'https://example.com/doc.md',
mediaType: 'text/markdown',
filename: 'doc.md',
},
],
},
],
};

const res = await makeRequest('/run/api/chat', {
method: 'POST',
body: JSON.stringify(body),
});

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

it('should accept inline image file part using Vercel FileUIPart shape', async () => {
const body = {
messages: [
Expand Down
Loading
Loading