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
6 changes: 5 additions & 1 deletion dev/src/server/adk_api_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,8 +857,12 @@ export class AdkApiServer {
);
await executeQuery(req.body);
} else {
// Decode through the stream's StringDecoder: a multi-byte UTF-8
// sequence straddling two chunks would otherwise become replacement
// characters on both sides of the boundary.
req.setEncoding('utf-8');
let rawBody = '';
req.on('data', (chunk) => {
req.on('data', (chunk: string) => {
rawBody += chunk;
});
req.on('end', async () => {
Expand Down
79 changes: 79 additions & 0 deletions dev/test/server/adk_api_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Session,
} from '@google/adk';
import {ReadableSpan} from '@opentelemetry/sdk-trace-base';
import * as http from 'node:http';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {z} from 'zod';

Expand Down Expand Up @@ -132,6 +133,57 @@ class HttpClient {
}
}

/** The U+FFFD standing in for bytes that did not decode as UTF-8. */
const REPLACEMENT_CHARACTER = '\uFFFD';

/**
* Fixture text made of 3-byte characters. `HTTP_CHUNK_BYTES` is not a multiple
* of three, so successive chunk boundaries land inside a UTF-8 sequence rather
* than between two of them.
*/
const MULTI_BYTE_TEXT = '世界你好'.repeat(16);

/** Bytes per chunked-transfer frame written by `postInChunks`. */
const HTTP_CHUNK_BYTES = 7;

/**
* POSTs `payload` to `url`, writing its UTF-8 bytes `HTTP_CHUNK_BYTES` at a
* time. With no `Content-Length` Node frames each `write()` as its own chunked
* transfer frame and the receiving parser emits one 'data' event per frame, so
* the handler observes exactly these byte boundaries.
*/
async function postInChunks(
url: string,
payload: string,
): Promise<{status: number}> {
const bytes = Buffer.from(payload, 'utf-8');
const target = new URL(url);

return new Promise((resolve, reject) => {
const request = http.request(
{
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: 'POST',
// A doubled media type is what Agent Engine sends; Express's JSON body
// parser declines it, which is what exercises the raw-body path.
headers: {'Content-Type': 'application/json,application/json'},
},
(response) => {
response.resume();
response.on('end', () => resolve({status: response.statusCode ?? 0}));
},
);

request.on('error', reject);
for (let offset = 0; offset < bytes.length; offset += HTTP_CHUNK_BYTES) {
request.write(bytes.subarray(offset, offset + HTTP_CHUNK_BYTES));
}
request.end();
});
}

class TestAgent extends LlmAgent {
async *runAsyncImpl(
context: InvocationContext,
Expand Down Expand Up @@ -1152,6 +1204,33 @@ describe('AdkWebServer', () => {
);
});

it('should not corrupt a multi-byte raw body split across chunk boundaries', async () => {
const payload = JSON.stringify({
input: {
appName: 'testApp',
userId: 'testUser',
sessionId: 'utf8SessionId',
newMessage: {parts: [{text: MULTI_BYTE_TEXT}], role: 'user'},
},
});

const response = await postInChunks(
`${server.url}/api/reasoning_engine`,
payload,
);

expect(response.status).toBe(200);
const session = await sessionService.getSession({
appName: 'testApp',
userId: 'testUser',
sessionId: 'utf8SessionId',
});
const userMessage = session?.events.find((e) => e.author === 'user')
?.content?.parts?.[0].text;
expect(userMessage?.indexOf(REPLACEMENT_CHARACTER)).toBe(-1);
expect(userMessage).toBe(MULTI_BYTE_TEXT);
});

it('should return 400 if appName is missing', async () => {
try {
await client.post('/api/reasoning_engine', {
Expand Down
Loading