mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): surface Managed session list failures Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): match the list-failure sentences, not the whole alert The list-failure alert wraps its message and its Retry button in the same role=alert div, so textContent reads 'Managed sessions could not be loaded.Retry loading sessions'. Two assertions compared that concatenation against the sentence alone, which made four cases fail with a message that looked identical on both sides. Assert the sentence is present, the way the sibling HTTP-500 case already does, and keep the alert count so the 'once' property is still pinned. Not verified locally: this checkout's core dist predates managed-runtime/managed-permission-rules.js, so the suite cannot load here. CI is the first real run. --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
1440 lines
47 KiB
TypeScript
1440 lines
47 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
isJavaAgentResyncRequired,
|
|
JavaManagedAgentClient,
|
|
SKIP_ON_CORRUPT,
|
|
} from './java-managed-agent-client';
|
|
import type { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
|
import { projectJavaAgentEvent } from './java-managed-agent-event-projector';
|
|
import {
|
|
corruptFrame as corrupt,
|
|
validEventFrame as valid,
|
|
} from './managed-agent-sse.test-fixtures';
|
|
|
|
function jsonResponse(value: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(value), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('JavaManagedAgentClient', () => {
|
|
it('sends Session file locators without a caller-selected Workspace or cwd', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
await client.queryWorkspaceFiles({
|
|
sessionId: 'session-a',
|
|
directory: '.',
|
|
});
|
|
await client.readWorkspaceFile({
|
|
sessionId: 'session-a',
|
|
path: 'file.txt',
|
|
expectedScopeRevision: 'scope',
|
|
});
|
|
await client.uploadWorkspaceFile(
|
|
{
|
|
sessionId: 'session-a',
|
|
path: 'file.txt',
|
|
expectedScopeRevision: 'scope',
|
|
uploadId: 'upload-id',
|
|
},
|
|
new Blob(['text']),
|
|
);
|
|
expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toEqual({
|
|
sessionId: 'session-a',
|
|
directory: '.',
|
|
});
|
|
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({
|
|
sessionId: 'session-a',
|
|
path: 'file.txt',
|
|
expectedScopeRevision: 'scope',
|
|
});
|
|
expect(
|
|
Object.fromEntries(
|
|
new URL(String(fetchImpl.mock.calls[2][0])).searchParams,
|
|
),
|
|
).toEqual({
|
|
sessionId: 'session-a',
|
|
path: 'file.txt',
|
|
expectedScopeRevision: 'scope',
|
|
uploadId: 'upload-id',
|
|
});
|
|
});
|
|
|
|
it('uploads raw bytes to the authenticated Workspace route and queries the same upload ID', async () => {
|
|
const result = {
|
|
scopeRevision: 'scope',
|
|
uploadId: 'upload-id',
|
|
path: 'folder/你好.pdf',
|
|
sizeBytes: 4,
|
|
sha256: 'a'.repeat(64),
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => jsonResponse(result));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example/gateway',
|
|
getHeaders: () => ({
|
|
authorization: 'Bearer scoped',
|
|
'content-type': 'application/json',
|
|
}),
|
|
fetch: fetchImpl,
|
|
});
|
|
const request = {
|
|
workspaceId: 'workspace',
|
|
cwdRelative: 'src',
|
|
path: result.path,
|
|
expectedScopeRevision: 'scope',
|
|
uploadId: result.uploadId,
|
|
};
|
|
const data = new Blob([new Uint8Array([0, 255, 128, 1])]);
|
|
const signal = new AbortController().signal;
|
|
expect(await client.uploadWorkspaceFile(request, data, signal)).toEqual(
|
|
result,
|
|
);
|
|
const [url, init] = fetchImpl.mock.calls[0];
|
|
const parsed = new URL(String(url));
|
|
expect(parsed.pathname).toBe(
|
|
'/gateway/api/agent/web-shell/v1/workspaces/files/upload',
|
|
);
|
|
expect(Object.fromEntries(parsed.searchParams)).toEqual(request);
|
|
expect(init?.body).toBe(data);
|
|
expect(init?.signal).toBe(signal);
|
|
expect(init?.credentials).toBe('include');
|
|
expect(new Headers(init?.headers).get('content-type')).toBe(
|
|
'application/octet-stream',
|
|
);
|
|
expect(new Headers(init?.headers).get('authorization')).toBe(
|
|
'Bearer scoped',
|
|
);
|
|
await client.queryWorkspaceFileUpload(result.uploadId, signal);
|
|
expect(fetchImpl.mock.calls[1][0]).toBe(
|
|
'https://product.example/gateway/api/agent/web-shell/v1/workspaces/files/uploads/query',
|
|
);
|
|
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({
|
|
uploadId: result.uploadId,
|
|
});
|
|
});
|
|
|
|
it('renames through the authenticated WebShell POST using the original key and signal', async () => {
|
|
const result = { sessionId: 's1', title: 'Confirmed', replayed: false };
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(jsonResponse(result));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example/gateway',
|
|
getHeaders: () => ({ authorization: 'Bearer scoped' }),
|
|
fetch: fetchImpl,
|
|
});
|
|
const abort = new AbortController();
|
|
const request = {
|
|
sessionId: 's1',
|
|
title: 'Confirmed',
|
|
idempotencyKey: 'original',
|
|
};
|
|
expect(await client.renameSession(request, abort.signal)).toEqual(result);
|
|
const [url, init] = fetchImpl.mock.calls[0];
|
|
expect(url).toBe(
|
|
'https://product.example/gateway/api/agent/web-shell/v1/sessions/rename',
|
|
);
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.credentials).toBe('include');
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(new Headers(init?.headers).get('authorization')).toBe(
|
|
'Bearer scoped',
|
|
);
|
|
expect(JSON.parse(String(init?.body))).toEqual(request);
|
|
});
|
|
|
|
it('does not treat an accepted or refused rename as confirmed', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ sessionId: 's1', title: 'Unconfirmed' }, 202),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse(
|
|
{
|
|
error: {
|
|
code: 'session_operation_forbidden',
|
|
message: 'Forbidden',
|
|
},
|
|
},
|
|
403,
|
|
),
|
|
);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
const request = {
|
|
sessionId: 's1',
|
|
title: 'Name',
|
|
idempotencyKey: 'original',
|
|
};
|
|
await expect(client.renameSession(request)).rejects.toMatchObject({
|
|
status: 202,
|
|
code: 'session_rename_unconfirmed',
|
|
});
|
|
await expect(client.renameSession(request)).rejects.toMatchObject({
|
|
status: 403,
|
|
code: 'session_operation_forbidden',
|
|
});
|
|
});
|
|
|
|
it('uses authorized Workspace registration POST routes without putting credentials in request bodies', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example/gateway',
|
|
getHeaders: () => ({ authorization: 'Bearer short-lived' }),
|
|
credentials: 'include',
|
|
fetch: fetchImpl,
|
|
});
|
|
const abort = new AbortController();
|
|
await client.listRuntimeNodes(abort.signal);
|
|
await client.checkWorkspaceDirectory(
|
|
{ nodeId: 'mac', path: '/project' },
|
|
abort.signal,
|
|
);
|
|
await client.registerWorkspace(
|
|
{
|
|
nodeId: 'mac',
|
|
path: '/project',
|
|
displayName: 'Project',
|
|
idempotencyKey: 'original',
|
|
},
|
|
abort.signal,
|
|
);
|
|
await client.getWorkspaceOperation('operation-a', abort.signal);
|
|
expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual([
|
|
'https://product.example/gateway/api/agent/web-shell/v1/runtime-nodes/query',
|
|
'https://product.example/gateway/api/agent/web-shell/v1/workspaces/check-directory',
|
|
'https://product.example/gateway/api/agent/web-shell/v1/workspaces/register',
|
|
'https://product.example/gateway/api/agent/web-shell/v1/workspaces/operations/get',
|
|
]);
|
|
expect(
|
|
fetchImpl.mock.calls.map(([, init]) => JSON.parse(String(init?.body))),
|
|
).toEqual([
|
|
{},
|
|
{ nodeId: 'mac', path: '/project' },
|
|
{
|
|
nodeId: 'mac',
|
|
path: '/project',
|
|
displayName: 'Project',
|
|
idempotencyKey: 'original',
|
|
},
|
|
{ operationId: 'operation-a' },
|
|
]);
|
|
for (const [, request] of fetchImpl.mock.calls) {
|
|
expect(request?.method).toBe('POST');
|
|
expect(request?.credentials).toBe('include');
|
|
expect(request?.signal).toBe(abort.signal);
|
|
expect(new Headers(request?.headers).get('authorization')).toBe(
|
|
'Bearer short-lived',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('queries and changes Session models using authenticated cancellable POSTs', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
getHeaders: () => ({ 'x-tenant': 'tenant-a' }),
|
|
});
|
|
const abort = new AbortController();
|
|
await client.getSessionModels('session-a', abort.signal);
|
|
const request = {
|
|
sessionId: 'session-a',
|
|
modelId: 'published',
|
|
expectedRevision: 2,
|
|
idempotencyKey: 'frozen',
|
|
};
|
|
await client.changeModel(request, abort.signal);
|
|
expect(
|
|
fetchImpl.mock.calls.map(([url, init]) => [
|
|
url,
|
|
JSON.parse(String(init?.body)),
|
|
]),
|
|
).toEqual([
|
|
[
|
|
'https://product.example/api/agent/web-shell/v1/session-models/query',
|
|
{ sessionId: 'session-a' },
|
|
],
|
|
[
|
|
'https://product.example/api/agent/web-shell/v1/session-models/change',
|
|
request,
|
|
],
|
|
]);
|
|
for (const [, init] of fetchImpl.mock.calls) {
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.credentials).toBe('include');
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(new Headers(init?.headers).get('x-tenant')).toBe('tenant-a');
|
|
}
|
|
});
|
|
it('queries effective Session options for the selected Agent with authenticated headers', async () => {
|
|
const options = {
|
|
approvalMode: 'default',
|
|
availableApprovalModes: ['default', 'auto-edit'],
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(jsonResponse(options));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
getHeaders: () => ({ 'x-tenant': 'tenant-a' }),
|
|
});
|
|
const abort = new AbortController();
|
|
expect(
|
|
await client.getSessionOptions('model-specific-agent', abort.signal),
|
|
).toEqual(options);
|
|
const [url, init] = fetchImpl.mock.calls[0];
|
|
expect(url).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/session-options/query',
|
|
);
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
agentId: 'model-specific-agent',
|
|
});
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.credentials).toBe('include');
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(new Headers(init?.headers).get('x-tenant')).toBe('tenant-a');
|
|
});
|
|
it('creates a Workspace with the frozen name and key through the authenticated POST route', async () => {
|
|
const workspace = {
|
|
workspaceId: 'created-a',
|
|
displayName: 'A',
|
|
state: 'active',
|
|
canCreateSession: true,
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(jsonResponse(workspace));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
getHeaders: () => ({ 'x-tenant': 'tenant-a' }),
|
|
});
|
|
const abort = new AbortController();
|
|
const request = { displayName: 'A', idempotencyKey: 'saved-key' };
|
|
expect(await client.createWorkspace(request, abort.signal)).toEqual(
|
|
workspace,
|
|
);
|
|
const [url, init] = fetchImpl.mock.calls[0];
|
|
expect(url).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/workspaces/create',
|
|
);
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.credentials).toBe('include');
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(new Headers(init?.headers).get('x-tenant')).toBe('tenant-a');
|
|
expect(JSON.parse(String(init?.body))).toEqual(request);
|
|
});
|
|
it('uses all existing task POST routes with headers, credentials and cancellation', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () =>
|
|
jsonResponse({ data: [], hasMore: false, nextCursor: 'floor' }),
|
|
);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
getHeaders: () => ({ 'x-tenant': 'tenant' }),
|
|
});
|
|
const abort = new AbortController();
|
|
await client.listTasks(
|
|
{ sessionId: 's1', cursor: 'page', limit: 50 },
|
|
abort.signal,
|
|
);
|
|
await client.getTask({ sessionId: 's1', taskId: 'task1' }, abort.signal);
|
|
await client.listTaskEvents(
|
|
{ sessionId: 's1', taskId: 'task1', after: 'checkpoint', limit: 50 },
|
|
abort.signal,
|
|
);
|
|
await client.cancelTask(
|
|
{
|
|
sessionId: 's1',
|
|
taskId: 'task1',
|
|
idempotencyKey: 'saved',
|
|
requestId: 'trace',
|
|
},
|
|
abort.signal,
|
|
);
|
|
expect(
|
|
fetchImpl.mock.calls.map(([url]) => String(url).split('/v1')[1]),
|
|
).toEqual([
|
|
'/tasks/query',
|
|
'/tasks/get',
|
|
'/tasks/events/query',
|
|
'/tasks/cancel',
|
|
]);
|
|
for (const [, init] of fetchImpl.mock.calls) {
|
|
expect(init?.method).toBe('POST');
|
|
expect(init?.credentials).toBe('include');
|
|
expect(init?.signal).toBe(abort.signal);
|
|
expect(new Headers(init?.headers).get('x-tenant')).toBe('tenant');
|
|
}
|
|
expect(JSON.parse(String(fetchImpl.mock.calls[2][1]?.body))).toEqual({
|
|
sessionId: 's1',
|
|
taskId: 'task1',
|
|
after: 'checkpoint',
|
|
limit: 50,
|
|
});
|
|
});
|
|
it('binds the default browser fetch to the global object', async () => {
|
|
const fetchImpl = vi.fn(function (this: unknown) {
|
|
if (this !== globalThis) throw new TypeError('Illegal invocation');
|
|
return Promise.resolve(jsonResponse({ data: [], hasMore: false }));
|
|
});
|
|
vi.stubGlobal('fetch', fetchImpl);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
});
|
|
|
|
await client.listSessions({ limit: 20 });
|
|
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('uses the private gateway with product credentials and headers', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(jsonResponse({ data: [], hasMore: false }));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example/',
|
|
fetch: fetchImpl,
|
|
getHeaders: () => ({ authorization: 'Bearer short-lived' }),
|
|
});
|
|
|
|
await client.listSessions({ limit: 20 });
|
|
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchImpl.mock.calls[0];
|
|
expect(url).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/sessions/query',
|
|
);
|
|
expect(init?.credentials).toBe('include');
|
|
expect(new Headers(init?.headers).get('authorization')).toBe(
|
|
'Bearer short-lived',
|
|
);
|
|
expect(JSON.parse(String(init?.body))).toEqual({ limit: 20 });
|
|
});
|
|
|
|
it('parses chunked CRLF SSE frames and ignores heartbeats', async () => {
|
|
const encoder = new TextEncoder();
|
|
const chunks = [
|
|
': keepalive\r\n\r\nid: 7\r\nevent: item.output_text.delta\r\ndata: {"sequence":7,"eventId":"evt_7",',
|
|
'"sessionId":"session-1","turnId":"turn-1","type":"item.output_text.delta","createdAt":7,"data":{"text":"hi"},"terminal":false}\r\n\r\n',
|
|
];
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
|
|
controller.close();
|
|
},
|
|
});
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(body, { status: 200 }));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 6,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
expect.objectContaining({ sequence: 7, data: { text: 'hi' } }),
|
|
]);
|
|
expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toEqual({
|
|
sessionId: 'session-1',
|
|
afterSequence: 6,
|
|
});
|
|
});
|
|
|
|
it('reports the stream as open once the response arrives, and not when it fails', async () => {
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(new ReadableStream<Uint8Array>({ start() {} }), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
});
|
|
const onOpen = vi.fn();
|
|
const iterator = client.streamEvents(
|
|
{ sessionId: 'session-1', afterSequence: 6 },
|
|
undefined,
|
|
onOpen,
|
|
);
|
|
// The stream idles without a single frame, yet the open signal fires:
|
|
// establishment is what a silent-but-healthy connection reports. The
|
|
// generator stays parked on the idle stream when the test is done.
|
|
void iterator.next();
|
|
await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce());
|
|
|
|
const failing = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
});
|
|
const onOpenFailure = vi.fn();
|
|
await expect(
|
|
failing
|
|
.streamEvents({ sessionId: 'session-1' }, undefined, onOpenFailure)
|
|
.next(),
|
|
).rejects.toThrow('Failed to fetch');
|
|
expect(onOpenFailure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports each complete heartbeat to onAlive, and not a trailing fragment', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const encoder = new TextEncoder();
|
|
const chunks = [
|
|
': keepalive\r\n\r\n',
|
|
'id: 7\r\nevent: item.output_text.delta\r\ndata: {"sequence":7,"eventId":"evt_7","sessionId":"session-1","turnId":"turn-1","type":"item.output_text.delta","createdAt":7,"data":{"text":"hi"},"terminal":false}\r\n\r\n',
|
|
': keepalive\r\n\r\n',
|
|
// A leftover partial frame at end of stream is a mid-frame
|
|
// disconnect, never a complete heartbeat: it is not reported.
|
|
': keepal',
|
|
];
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
|
|
controller.close();
|
|
},
|
|
});
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(body, { status: 200 }));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
const onAlive = vi.fn();
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents(
|
|
{ sessionId: 'session-1', afterSequence: 6 },
|
|
undefined,
|
|
undefined,
|
|
onAlive,
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
expect.objectContaining({ sequence: 7, data: { text: 'hi' } }),
|
|
]);
|
|
expect(onAlive).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('decodes the resync frame that ends an expired stream', async () => {
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response(
|
|
'event:agent.session.resync_required\ndata:{"type":"agent.session.resync_required","sessionId":"session-1","replayFloorSequence":40,"snapshotThroughSequence":42,"action":"reload_snapshot"}\n\n',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 3,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toEqual([
|
|
{
|
|
type: 'agent.session.resync_required',
|
|
sessionId: 'session-1',
|
|
replayFloorSequence: 40,
|
|
snapshotThroughSequence: 42,
|
|
action: 'reload_snapshot',
|
|
},
|
|
]);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
});
|
|
|
|
it('skips one corrupt frame and delivers the frames behind it', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// The persisted frame at sequence 2 has a truncated JSON payload.
|
|
'id: 2\r\nevent: item.output_text.delta\r\ndata: {"sequence":2,"eventId":"evt_2"\r\n\r\n' +
|
|
'id: 3\r\nevent: item.output_text.delta\r\ndata: {"sequence":3,"eventId":"evt_3","sessionId":"session-1","turnId":"turn-1","type":"item.output_text.delta","createdAt":3,"data":{"text":"after"},"terminal":false}\r\n\r\n',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
expect.objectContaining({ sequence: 3, data: { text: 'after' } }),
|
|
]);
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('resyncs past a run of only corrupt frames instead of aborting', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
// The valid frame behind the corrupt run disables the end-of-stream
|
|
// fallback, so only the budget trip can produce the first frame.
|
|
new Response(
|
|
corrupt(2) + corrupt(3) + corrupt(4) + corrupt(5) + valid(6),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 6 }));
|
|
// Rate-limited skip warning for the first frame, plus the resync warning.
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('resyncs when a connection delivers nothing but skipped corrupt frames', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
// Two corrupt frames stay under the resync budget, but nothing valid
|
|
// was delivered before the stream ended: retrying the same cursor
|
|
// would replay them identically, so the stream ends with a resync.
|
|
new Response(corrupt(2) + corrupt(3), { status: 200 }),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(1);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
});
|
|
|
|
it('resyncs when a connection delivers nothing but a single skipped frame', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
// One skip is already one too many: nothing valid was delivered, so
|
|
// retrying the same cursor replays this frame identically. The stream
|
|
// ends normally here, so without the resync the hook reconnects every
|
|
// 3s forever with a frozen transcript and no visible error.
|
|
new Response(corrupt(2), { status: 200 }),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(1);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
// The skip warning plus the end-of-stream resync warning: a resync from
|
|
// the fail-closed name check would log once instead, so this pins which
|
|
// guard produced the frame.
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('resyncs on a corrupt action update frame instead of skipping it', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// A skipped action update leaves a pending approval unreachable.
|
|
// The valid frame behind it disables the end-of-stream fallback,
|
|
// so only the name-based resync can produce the first frame.
|
|
'id: 2\r\nevent: action.updated\r\ndata: {"sequence":2,"eventId":"evt_2"\r\n\r\n' +
|
|
valid(3),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 3 }));
|
|
});
|
|
|
|
it.each([
|
|
'stream.reconciled',
|
|
'turn.accepted',
|
|
'turn.completed',
|
|
'turn.failed',
|
|
'turn.cancelled',
|
|
'item.tool_call.updated',
|
|
'item.tool_result.updated',
|
|
])('resyncs on a corrupt %s frame instead of skipping it', async (name) => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response(corrupt(2, name) + valid(3), { status: 200 }),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 3 }));
|
|
});
|
|
|
|
it.each([...SKIP_ON_CORRUPT])(
|
|
'skips a corrupt %s frame and delivers what is behind it',
|
|
async (name) => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response(corrupt(2, name) + valid(3), { status: 200 }),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toEqual([expect.objectContaining({ sequence: 3 })]);
|
|
},
|
|
);
|
|
|
|
it('resyncs on a corrupt frame with an unknown event name', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(corrupt(2, 'item.brand.new') + valid(3), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 3 }));
|
|
});
|
|
|
|
it('resyncs on a corrupt frame with no event name at all', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response(
|
|
'id: 2\r\ndata: {"sequence":2,"eventId":"evt_2"\r\n\r\n' + valid(3),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 3 }));
|
|
});
|
|
|
|
it('only allows skipping names that project to a UI event', () => {
|
|
for (const name of SKIP_ON_CORRUPT) {
|
|
expect(
|
|
projectJavaAgentEvent({
|
|
sequence: 2,
|
|
eventId: 'evt_2',
|
|
sessionId: 'session-1',
|
|
turnId: 'turn-1',
|
|
type: name,
|
|
createdAt: 2,
|
|
data: {},
|
|
terminal: false,
|
|
}),
|
|
).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('accumulates the corrupt budget across read chunks', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const encoder = new TextEncoder();
|
|
const chunks = [
|
|
corrupt(2) + corrupt(3),
|
|
corrupt(4) + corrupt(5) + valid(6),
|
|
];
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
|
|
controller.close();
|
|
},
|
|
});
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(body, { status: 200 })),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
// The corrupt run straddles two reads: the fourth corrupt frame still
|
|
// trips the budget.
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 6 }));
|
|
});
|
|
|
|
it('tolerates three consecutive corrupt frames when a valid frame resets the count', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
// A run of exactly three consecutive corrupt frames (4-6) stays under
|
|
// the budget, and the valid frame at 3 resets an earlier count.
|
|
new Response(
|
|
corrupt(2) +
|
|
valid(3) +
|
|
corrupt(4) +
|
|
corrupt(5) +
|
|
corrupt(6) +
|
|
valid(7),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
expect.objectContaining({ sequence: 3 }),
|
|
expect.objectContaining({ sequence: 7 }),
|
|
]);
|
|
// Skip warnings are rate-limited: only the first skip logs.
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rate-limits skip warnings when corruption alternates with valid frames', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
let stream = '';
|
|
for (let index = 2; index <= 41; index += 1) {
|
|
stream += index % 2 === 0 ? corrupt(index) : valid(index);
|
|
}
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(stream, { status: 200 })),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toHaveLength(20);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('logs a mid-frame close distinctly and spares the budget', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
'id: 2\r\nevent: item.output_text.delta\r\ndata: {"sequence":2,"eventId":"evt_2","sessionId":"session-1","turnId":"turn-1","type":"item.output_text.delta","createdAt":2,"data":{},"terminal":true}\r\n\r\n' +
|
|
// Truncated final frame without the terminating blank line.
|
|
'id: 3\r\nevent: item.output_text.delta\r\ndata: {"sequence":3,"ev',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([expect.objectContaining({ sequence: 2 })]);
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('closed mid-frame'),
|
|
);
|
|
});
|
|
|
|
it('warns when the stream closes before the trailing frame even reaches its data line', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
valid(2) +
|
|
// The tear lands before the data: line: no payload at all.
|
|
'id: 3\r\nevent: item.output_text.delta\r\n',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([expect.objectContaining({ sequence: 2 })]);
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('closed mid-frame'),
|
|
);
|
|
});
|
|
|
|
it('charges the budget for corrupt frames but not for a mid-frame close', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// Three corrupt frames stay under the budget; the torn tail must
|
|
// not take the fourth slot. With nothing delivered at all, the end
|
|
// of the stream still surfaces the loss.
|
|
corrupt(2) +
|
|
corrupt(3) +
|
|
corrupt(4) +
|
|
'id: 5\r\nevent: item.output_text.delta\r\ndata: {"sequence":5,"ev',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(1);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('closed mid-frame'),
|
|
);
|
|
});
|
|
|
|
it('counts a mid-stream frame without a data line as corrupt', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// A complete frame with id/event lines but no data line is
|
|
// malformed server output, not a heartbeat.
|
|
'id: 2\r\nevent: item.output_text.delta\r\n\r\n' + valid(3),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([expect.objectContaining({ sequence: 3 })]);
|
|
expect(warn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('charges the budget for mid-stream frames without a data line', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const noData = (sequence: number) =>
|
|
`id: ${sequence}\r\nevent: item.output_text.delta\r\n\r\n`;
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
// Four no-data frames trip the budget; the valid frame behind them
|
|
// disables the end-of-stream fallback.
|
|
new Response(noData(2) + noData(3) + noData(4) + noData(5) + valid(6), {
|
|
status: 200,
|
|
}),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 6 }));
|
|
});
|
|
|
|
it('does not count heartbeats toward the corrupt-frame budget', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response(
|
|
': keepalive\r\n\r\n'.repeat(5) +
|
|
'id: 2\r\nevent: item.output_text.delta\r\ndata: {"sequence":2,"eventId":"evt_2","sessionId":"session-1","turnId":"turn-1","type":"item.output_text.delta","createdAt":2,"data":{},"terminal":false}\r\n\r\n',
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([expect.objectContaining({ sequence: 2 })]);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not let heartbeats dilute the corrupt-frame budget', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const heartbeat = ': keepalive\r\n\r\n';
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// The valid frame behind the corrupt run disables the
|
|
// end-of-stream fallback, so only the budget trip can produce the
|
|
// first frame.
|
|
corrupt(2) +
|
|
heartbeat +
|
|
corrupt(3) +
|
|
heartbeat +
|
|
corrupt(4) +
|
|
heartbeat +
|
|
corrupt(5) +
|
|
valid(6),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 6 }));
|
|
});
|
|
|
|
it('counts data frames without a string event type as corrupt', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
// Valid JSON, but none of these is an event object (the schema
|
|
// requires a string `type`): as corrupt as broken JSON.
|
|
'id: 2\r\nevent: item.output_text.delta\r\ndata: null\r\n\r\n' +
|
|
valid(3) +
|
|
'id: 4\r\nevent: item.output_text.delta\r\ndata: []\r\n\r\n' +
|
|
valid(5) +
|
|
'id: 6\r\nevent: item.output_text.delta\r\ndata: {}\r\n\r\n' +
|
|
valid(7),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const events = [];
|
|
for await (const event of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 1,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
expect.objectContaining({ sequence: 3 }),
|
|
expect.objectContaining({ sequence: 5 }),
|
|
expect.objectContaining({ sequence: 7 }),
|
|
]);
|
|
// Rate-limited: only the first skip logs.
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('treats a corrupt id-less resync frame as a resync request', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
'event:agent.session.resync_required\r\ndata:{"type":"agent.session.resync_required","sessionId":"sess\r\n\r\n' +
|
|
// The valid frame behind it disables the end-of-stream fallback,
|
|
// so only the fail-closed resync rule can produce the first
|
|
// frame.
|
|
valid(4),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
});
|
|
|
|
const frames = [];
|
|
for await (const frame of client.streamEvents({
|
|
sessionId: 'session-1',
|
|
afterSequence: 3,
|
|
})) {
|
|
frames.push(frame);
|
|
}
|
|
|
|
expect(frames).toHaveLength(2);
|
|
expect(isJavaAgentResyncRequired(frames[0]!)).toBe(true);
|
|
expect(frames[1]).toEqual(expect.objectContaining({ sequence: 4 }));
|
|
});
|
|
|
|
it('maps the stable Java error envelope', async () => {
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: vi.fn<typeof fetch>().mockResolvedValue(
|
|
jsonResponse(
|
|
{
|
|
error: {
|
|
code: 'agent_api_idempotency_conflict',
|
|
message: 'conflict',
|
|
request_id: 'request-409',
|
|
},
|
|
},
|
|
409,
|
|
),
|
|
),
|
|
});
|
|
|
|
await expect(
|
|
client.createSession({
|
|
requestId: 'r1',
|
|
idempotencyKey: 'key-1',
|
|
agentId: 'dataworks_data_agent',
|
|
input: [{ type: 'input_text', text: 'hello' }],
|
|
}),
|
|
).rejects.toEqual(
|
|
expect.objectContaining<Partial<JavaManagedAgentHttpError>>({
|
|
status: 409,
|
|
code: 'agent_api_idempotency_conflict',
|
|
message: 'conflict',
|
|
requestId: 'request-409',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('sends the cancel-critical fields verbatim', async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
|
jsonResponse({
|
|
sessionId: 's1',
|
|
turnId: 'p1',
|
|
status: 'accepted',
|
|
replayed: false,
|
|
operationId: 'cancel-1',
|
|
admissionStage: 'java_durable',
|
|
deliveryState: 'pending',
|
|
}),
|
|
);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
await client.cancelTurn({
|
|
requestId: 'rid-1',
|
|
idempotencyKey: 'key-cancel',
|
|
sessionId: 's1',
|
|
turnId: 'p1',
|
|
});
|
|
|
|
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
expect(String(url)).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/turns/cancel',
|
|
);
|
|
// A dropped turnId or idempotencyKey cancels nothing and dedupe-breaks.
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
requestId: 'rid-1',
|
|
idempotencyKey: 'key-cancel',
|
|
sessionId: 's1',
|
|
turnId: 'p1',
|
|
});
|
|
});
|
|
|
|
it('loads one recent Item page and passes its opaque older cursor verbatim', async () => {
|
|
const page = {
|
|
items: [{ id: 'recent-item' }],
|
|
nextCursor: 'signed-backward.cursor',
|
|
hasMore: true,
|
|
snapshotThroughSequence: 120,
|
|
};
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(jsonResponse(page));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
const signal = new AbortController().signal;
|
|
expect(
|
|
await client.getRecentTranscriptItems(
|
|
{ sessionId: 's1', cursor: 'signed-backward.cursor', limit: 2 },
|
|
signal,
|
|
),
|
|
).toEqual(page);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
expect(String(url)).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/transcript/items/query',
|
|
);
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
sessionId: 's1',
|
|
cursor: 'signed-backward.cursor',
|
|
limit: 2,
|
|
});
|
|
expect(init?.signal).toBe(signal);
|
|
});
|
|
|
|
it('surfaces an expired recent Item cursor without draining or replaying history', async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
error: { code: 'cursor_expired', message: 'Expired' },
|
|
}),
|
|
{
|
|
status: 409,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
),
|
|
);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
await expect(
|
|
client.getRecentTranscriptItems({
|
|
sessionId: 's1',
|
|
cursor: 'expired',
|
|
limit: 2,
|
|
}),
|
|
).rejects.toMatchObject({ code: 'cursor_expired', status: 409 });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('sends getSession, transcript and submit surfaces verbatim', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => jsonResponse({}));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
await client.getSession('s1');
|
|
await client.getTranscript({
|
|
sessionId: 's1',
|
|
cursor: 'before-1',
|
|
limit: 50,
|
|
});
|
|
await client.submitTurn({
|
|
requestId: 'rid-2',
|
|
idempotencyKey: 'key-submit',
|
|
sessionId: 's1',
|
|
input: [{ type: 'input_text', text: 'go' }],
|
|
});
|
|
|
|
const getCall = fetchImpl.mock.calls[0]!;
|
|
const txCall = fetchImpl.mock.calls[1]!;
|
|
const submitCall = fetchImpl.mock.calls[2]!;
|
|
expect(String(getCall[0])).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/sessions/get',
|
|
);
|
|
expect(JSON.parse(String(getCall[1]?.body))).toEqual({ sessionId: 's1' });
|
|
expect(String(txCall[0])).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/transcript/query',
|
|
);
|
|
expect(JSON.parse(String(txCall[1]?.body))).toEqual({
|
|
sessionId: 's1',
|
|
cursor: 'before-1',
|
|
limit: 50,
|
|
});
|
|
expect(String(submitCall[0])).toBe(
|
|
'https://product.example/api/agent/web-shell/v1/turns/submit',
|
|
);
|
|
expect(JSON.parse(String(submitCall[1]?.body))).toEqual({
|
|
requestId: 'rid-2',
|
|
idempotencyKey: 'key-submit',
|
|
sessionId: 's1',
|
|
input: [{ type: 'input_text', text: 'go' }],
|
|
});
|
|
});
|
|
|
|
it('falls back to the stable http error code for a non-JSON gateway error', async () => {
|
|
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response('<html>bad gateway</html>', {
|
|
status: 502,
|
|
headers: {
|
|
'content-type': 'text/html',
|
|
'x-request-id': 'gateway-request-502',
|
|
},
|
|
}),
|
|
);
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
// A gateway's HTML 502 must not escape as a raw SyntaxError.
|
|
await expect(client.getSession('s1')).rejects.toEqual(
|
|
expect.objectContaining<Partial<JavaManagedAgentHttpError>>({
|
|
status: 502,
|
|
code: 'http_502',
|
|
requestId: 'gateway-request-502',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects a null event-stream body as unavailable', async () => {
|
|
const fetchImpl = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(null, { status: 200 }));
|
|
const client = new JavaManagedAgentClient({
|
|
baseUrl: 'https://product.example',
|
|
fetch: fetchImpl,
|
|
});
|
|
|
|
await expect(
|
|
client.streamEvents({ sessionId: 's1' }).next(),
|
|
).rejects.toEqual(
|
|
expect.objectContaining<Partial<JavaManagedAgentHttpError>>({
|
|
code: 'agent_api_stream_unavailable',
|
|
}),
|
|
);
|
|
});
|
|
});
|