Files
qwen-code/packages/web-shell/client/components/managed/java-managed-agent-client.ts
T
9dde2f657d fix(web-shell): surface Managed session list failures (#16082)
* 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]>
2026-11-02 10:41:58 +08:00

1153 lines
34 KiB
TypeScript

import type {
ManagedExtensionManifest,
ManagedExtensionPin,
ManagedExtensionCatalog,
ManagedExtensionPackageOperation,
ManagedExtensionSettings,
ManagedExtensionSnapshot,
ManagedExtensionSelection,
} from './managed-extension-types';
import type { components } from './generated/managed-agent-api';
import type { ManagedMcpManagementSnapshot } from './managed-mcp-management-types';
import type {
ManagedWorkspaceFileQueryRequest,
ManagedWorkspaceFileReadRequest,
ManagedWorkspaceFileUploadRequest,
} from './managed-agent-provider';
import type {
ManagedArtifact,
ManagedArtifactPage,
ManagedArtifactResponse,
ManagedToolResultResponse,
} from './managed-tool-result-types';
type Schemas = components['schemas'];
export type JavaAgentTurn = Schemas['WebShellTurn'];
export interface JavaAgentEnvironment {
environmentId?: string;
state?: string;
errorCode?: string;
}
export type JavaAgentSession = Omit<
Schemas['WebShellSession'],
'environment'
> & {
environment?: JavaAgentEnvironment | null;
};
export type JavaAgentWorkspace = Schemas['WebShellWorkspace'];
export type JavaAgentWorkspacePage = Schemas['WebShellWorkspacePage'];
export type JavaAgentSessionPage = Omit<
Schemas['WebShellSessionPage'],
'data'
> & {
data: JavaAgentSession[];
};
export type JavaAgentEvent = Schemas['WebShellEvent'];
export type JavaAgentResyncRequired = Schemas['WebShellResyncRequired'];
const RESYNC_REQUIRED = 'agent.session.resync_required';
// Corrupt frames tolerated in a row before the stream resyncs past the
// poisoned run. Only a decoded event resets the count: heartbeats must not
// dilute a stream that serves nothing but corrupt frames.
const MAX_CONSECUTIVE_CORRUPT_FRAMES = 3;
// Skip warnings are rate-limited per connection: the first carries the frame
// identity, and alternating corruption would otherwise log at stream rate.
const SKIP_LOG_EVERY = 50;
// The only corrupt frames a skip can survive: delta text the durable
// snapshot re-assembles. Anything else resyncs — a lost approval update
// leaves the Hosted approval unreachable, a lost turn terminal leaves the
// last message streaming forever, a lost turn.accepted drops the user's own
// prompt, a lost stream.reconciled keeps retracted text on screen, and an
// unknown name is by definition one the panel has not budgeted for.
export const SKIP_ON_CORRUPT: ReadonlySet<string> = new Set([
'item.output_text.delta',
'item.reasoning.delta',
]);
export function isJavaAgentResyncRequired(
frame: JavaAgentEvent | JavaAgentResyncRequired,
): frame is JavaAgentResyncRequired {
return frame.type === RESYNC_REQUIRED && !('sequence' in frame);
}
export type JavaAgentContentPart = Schemas['WebShellContentPart'];
export type JavaAgentItem = Schemas['WebShellItem'];
export type JavaAgentCommandAdmission = Schemas['WebShellAdmission'];
export type JavaAgentTurnAdmission = Schemas['WebShellTurnAdmission'];
export type JavaAgentTranscript = Schemas['WebShellTranscript'];
export type JavaAgentAction = Schemas['WebShellAction'];
export type JavaAgentActionPage = Schemas['WebShellActionPage'];
export interface JavaManagedAgentClientOptions {
baseUrl: string;
getHeaders?: () => HeadersInit | Promise<HeadersInit>;
credentials?: RequestCredentials;
fetch?: typeof fetch;
}
export class JavaManagedAgentHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId?: string,
) {
super(message);
this.name = 'JavaManagedAgentHttpError';
}
}
const API_PREFIX = '/api/agent/web-shell/v1';
export class JavaManagedAgentClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly credentials: RequestCredentials;
constructor(private readonly options: JavaManagedAgentClientOptions) {
const base = new URL(
options.baseUrl,
typeof window === 'undefined'
? 'http://localhost'
: window.location.origin,
);
base.search = '';
base.hash = '';
this.baseUrl = `${base.origin}${base.pathname.replace(/\/+$/, '')}`;
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
this.credentials = options.credentials ?? 'include';
}
listSessions(
request: Schemas['WebShellListRequest'],
signal?: AbortSignal,
): Promise<JavaAgentSessionPage> {
return this.post('/sessions/query', request, signal);
}
getSession(sessionId: string, signal?: AbortSignal) {
return this.post<JavaAgentSession>('/sessions/get', { sessionId }, signal);
}
getRecovery(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['SessionRecovery']>(
'/session-recovery/query',
{ sessionId },
signal,
);
}
recheckRecovery(sessionId: string, turnId: string, signal?: AbortSignal) {
return this.post<Schemas['SessionRecovery']>(
'/session-recovery/recheck',
{ sessionId, turnId },
signal,
);
}
async renameSession(
request: Schemas['WebShellRenameRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellRenameResult']> {
const response = await this.request(
'/sessions/rename',
request,
signal,
false,
);
if (response.status !== 200) {
throw new JavaManagedAgentHttpError(
response.status,
'session_rename_unconfirmed',
'Managed Agent rename was not confirmed',
);
}
return (await response.json()) as Schemas['WebShellRenameResult'];
}
listWorkspaces(
request: Schemas['WebShellWorkspaceQueryRequest'],
signal?: AbortSignal,
): Promise<JavaAgentWorkspacePage> {
return this.post('/workspaces/query', request, signal);
}
getWorkspace(workspaceId: string, signal?: AbortSignal) {
return this.post<JavaAgentWorkspace>(
'/workspaces/get',
{ workspaceId },
signal,
);
}
listRuntimeNodes(signal?: AbortSignal) {
return this.post<Schemas['WebShellRuntimeNodePage']>(
'/runtime-nodes/query',
{},
signal,
);
}
checkWorkspaceDirectory(
request: Schemas['WebShellWorkspaceDirectoryRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellWorkspaceDirectory']>(
'/workspaces/check-directory',
request,
signal,
);
}
registerWorkspace(
request: Schemas['WebShellWorkspaceRegisterRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellWorkspaceCreateOperation']>(
'/workspaces/register',
request,
signal,
);
}
getWorkspaceOperation(operationId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellWorkspaceCreateOperation']>(
'/workspaces/operations/get',
{ operationId },
signal,
);
}
createWorkspace(
request: Schemas['WebShellWorkspaceCreateRequest'],
signal?: AbortSignal,
): Promise<JavaAgentWorkspace> {
return this.post('/workspaces/create', request, signal);
}
queryWorkspaceFiles(
request: ManagedWorkspaceFileQueryRequest,
signal?: AbortSignal,
): Promise<Schemas['WebShellWorkspaceFilePage']> {
return this.post('/workspaces/files/query', request, signal);
}
readWorkspaceFile(
request: ManagedWorkspaceFileReadRequest,
signal?: AbortSignal,
): Promise<Schemas['WebShellWorkspaceFileSnapshot']> {
return this.post('/workspaces/files/read', request, signal);
}
async uploadWorkspaceFile(
request: ManagedWorkspaceFileUploadRequest,
data: Blob,
signal?: AbortSignal,
): Promise<Schemas['WebShellWorkspaceFileUploadResult']> {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(request)) {
if (value !== undefined) query.set(key, value);
}
const response = await this.fetchRequest(
`/workspaces/files/upload?${query}`,
data,
signal,
false,
'application/octet-stream',
);
return (await response.json()) as Schemas['WebShellWorkspaceFileUploadResult'];
}
queryWorkspaceFileUpload(
uploadId: string,
signal?: AbortSignal,
): Promise<Schemas['WebShellWorkspaceFileUploadStatus']> {
return this.post('/workspaces/files/uploads/query', { uploadId }, signal);
}
workspaceDirectories(
request: Schemas['WebShellWorkspaceDirectoryQueryRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellWorkspaceDirectoryPage']> {
return this.post('/workspaces/directories/query', request, signal);
}
getSessionApproval(
sessionId: string,
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionApproval']> {
return this.post('/session-approval/query', { sessionId }, signal);
}
changeApproval(
request: Schemas['WebShellChangeApprovalRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellApprovalChangeResult']> {
return this.post('/session-approval/change', request, signal);
}
getSessionOptions(
agentId: string,
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionOptions']> {
return this.post('/session-options/query', { agentId }, signal);
}
lifecycleCommand(
kind: 'close' | 'archive' | 'delete',
request: Schemas['WebShellLifecycleRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellCommandOperation']>(
`/sessions/${kind}`,
request,
signal,
);
}
unarchiveSession(
request: Schemas['WebShellLifecycleRequest'],
signal?: AbortSignal,
) {
return this.post<JavaAgentSession>('/sessions/unarchive', request, signal);
}
exportSnapshot(
request: Schemas['WebShellExportRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellExportPage']>(
'/sessions/export/query',
request,
signal,
);
}
getTranscript(
request: Schemas['WebShellTranscriptRequest'],
signal?: AbortSignal,
): Promise<JavaAgentTranscript> {
return this.post('/transcript/query', request, signal);
}
getRecentTranscriptItems(
request: Schemas['WebShellExportRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellExportPage']> {
return this.post('/transcript/items/query', request, signal);
}
createSession(
request: Schemas['WebShellCreateRequest'],
signal?: AbortSignal,
): Promise<JavaAgentCommandAdmission> {
return this.post('/sessions/create', request, signal);
}
worktreeOptions(
request: Schemas['WebShellSessionWorktreeOptionsRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionWorktreeOptions']> {
return this.post('/workspaces/worktrees/options', request, signal);
}
listSessionWorktrees(
workspaceId: string,
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionWorktreePage']> {
return this.post('/workspaces/worktrees/query', { workspaceId }, signal);
}
inspectSessionWorktree(
workspaceId: string,
worktreeId: string,
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionWorktreeInspection']> {
return this.post(
'/workspaces/worktrees/inspect',
{ workspaceId, worktreeId },
signal,
);
}
cleanupSessionWorktree(
request: Schemas['WebShellSessionWorktreeCleanupRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellSessionWorktree']> {
return this.post('/workspaces/worktrees/cleanup', request, signal);
}
submitTurn(
request: Schemas['WebShellSubmitRequest'],
signal?: AbortSignal,
): Promise<JavaAgentTurnAdmission> {
return this.post('/turns/submit', request, signal);
}
cancelTurn(
request: Schemas['WebShellCancelRequest'],
signal?: AbortSignal,
): Promise<JavaAgentTurnAdmission> {
return this.post('/turns/cancel', request, signal);
}
queryActions(
request: Schemas['WebShellActionQueryRequest'],
signal?: AbortSignal,
): Promise<JavaAgentActionPage> {
return this.post('/actions/query', request, signal);
}
listTasks(
request: Schemas['WebShellTaskQueryRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellTaskPage']>(
'/tasks/query',
request,
signal,
);
}
getTask(request: Schemas['WebShellTaskGetRequest'], signal?: AbortSignal) {
return this.post<Schemas['WebShellTask']>('/tasks/get', request, signal);
}
listTaskEvents(
request: Schemas['WebShellTaskEventQueryRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellTaskEventPage']>(
'/tasks/events/query',
request,
signal,
);
}
cancelTask(
request: Schemas['WebShellTaskCancelRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellCommandOperation']>(
'/tasks/cancel',
request,
signal,
);
}
respondAction(
request: Schemas['WebShellActionRespondRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellCommandOperation']> {
return this.post('/actions/respond', request, signal);
}
/** Reads an operation's latest durable status; polling is not a replay. */
queryOperation(
request: Schemas['WebShellOperationRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellOperation']> {
return this.post('/operations/query', request, signal);
}
compressContext(
request: Schemas['WebShellContextCompressionRequest'],
signal?: AbortSignal,
): Promise<Schemas['WebShellContextCompressionOperation']> {
return this.post('/sessions/compress', request, signal);
}
getSessionModels(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellSessionModels']>(
'/session-models/query',
{ sessionId },
signal,
);
}
getSessionSkills(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellSessionSkills']>(
'/session-skills/query',
{ sessionId },
signal,
);
}
getSessionMcp(sessionId: string, signal?: AbortSignal) {
return this.post<Schemas['PublicMcpCatalog']>(
'/session-mcp/query',
{ sessionId },
signal,
);
}
listExtensionPackages(workspaceId: string, signal?: AbortSignal) {
return this.post<ManagedExtensionCatalog>(
'/workspace-extensions/query',
{ workspaceId },
signal,
);
}
async publishExtensionPackage(
workspaceId: string,
extensionId: string | undefined,
packageDigest: string,
manifest: ManagedExtensionManifest,
archive: Blob,
idempotencyKey: string,
signal?: AbortSignal,
): Promise<ManagedExtensionPackageOperation> {
if (archive.size > 100 * 1024 * 1024)
throw new Error('Managed package exceeds 100 MiB');
const encoded = btoa(
String.fromCharCode(
...new TextEncoder().encode(JSON.stringify(manifest)),
),
)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
const query = new URLSearchParams({
workspaceId,
packageDigest,
manifest: encoded,
});
if (extensionId) query.set('extensionId', extensionId);
const headers = new Headers(await this.options.getHeaders?.());
headers.set('Content-Type', 'application/octet-stream');
headers.set('Idempotency-Key', idempotencyKey);
const response = await this.fetchImpl(
`${this.baseUrl}${API_PREFIX}/workspace-extensions/publish?${query}`,
{
method: 'POST',
headers,
body: archive,
credentials: this.credentials,
signal,
},
);
if (!response.ok) throw await toHttpError(response);
return response.json();
}
getExtensionSettings(
workspaceId: string,
extensionId: string,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionSettings>(
'/workspace-extensions/settings/query',
{ workspaceId, extensionId },
signal,
);
}
saveExtensionSettings(
workspaceId: string,
extensionId: string,
expectedRevision: number,
values: Record<string, string>,
idempotencyKey: string,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionPackageOperation>(
'/workspace-extensions/settings/submit',
{ workspaceId, extensionId, expectedRevision, values, idempotencyKey },
signal,
);
}
queryExtensionConfiguration(
sessionId: string,
operationId?: string,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionSnapshot>(
'/session-extensions/query',
{ sessionId, ...(operationId ? { operationId } : {}) },
signal,
);
}
applyExtensionConfiguration(
sessionId: string,
command: ManagedExtensionSelection,
idempotencyKey: string,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionSnapshot>(
'/session-extensions/submit',
{ sessionId, command, idempotencyKey },
signal,
);
}
resumeExtensionConfiguration(
sessionId: string,
operationId: string,
recoverPrevious: boolean,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionSnapshot>(
`/session-extensions/${recoverPrevious ? 'recover-previous' : 'continue'}`,
{ sessionId, operationId },
signal,
);
}
getExtensionPackage(
workspaceId: string,
pin: ManagedExtensionPin,
signal?: AbortSignal,
) {
return this.post<ManagedExtensionCatalog['extensions'][number]>(
'/workspace-extensions/packages/query',
{ workspaceId, pin },
signal,
);
}
queryMcpManagement(
request: Schemas['WebShellMcpManagementQuery'],
signal?: AbortSignal,
) {
return this.post<ManagedMcpManagementSnapshot>(
'/session-mcp/management/query',
request,
signal,
);
}
submitMcpManagement(
request: Schemas['WebShellMcpManagementSubmit'],
signal?: AbortSignal,
) {
return this.post<ManagedMcpManagementSnapshot>(
'/session-mcp/management/submit',
request,
signal,
);
}
getMcpManagementOperation(
request: Schemas['WebShellMcpManagementOperationQuery'],
signal?: AbortSignal,
) {
return this.post<ManagedMcpManagementSnapshot>(
'/session-mcp/management/operations/query',
request,
signal,
);
}
changeModel(
request: Schemas['WebShellChangeModelRequest'],
signal?: AbortSignal,
) {
return this.post<Schemas['WebShellModelChangeResult']>(
'/session-models/change',
request,
signal,
);
}
getToolDetails(sessionId: string, itemId: string, signal?: AbortSignal) {
return this.post<Schemas['WebShellToolDetails']>(
'/tool-details/get',
{ sessionId, itemId },
signal,
);
}
getToolResult(sessionId: string, itemId: string, signal?: AbortSignal) {
return this.post<ManagedToolResultResponse>(
'/tool-results/get',
{ sessionId, itemId },
signal,
);
}
listArtifacts(
request: { sessionId: string; cursor?: string; limit?: number },
signal?: AbortSignal,
) {
return this.post<ManagedArtifactPage>('/artifacts/query', request, signal);
}
getArtifact(sessionId: string, artifactId: string, signal?: AbortSignal) {
return this.post<ManagedArtifactResponse>(
'/artifacts/get',
{ sessionId, artifactId },
signal,
);
}
async readArtifactRange(
artifact: ManagedArtifact,
offset: number,
length: number,
signal?: AbortSignal,
): Promise<Uint8Array> {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 1 ||
length > 1024 * 1024 ||
offset >= artifact.byte_length
) {
throw new Error('Invalid artifact byte range');
}
const end = Math.min(offset + length, artifact.byte_length) - 1;
const response = await this.artifactContent(
artifact,
signal,
`bytes=${offset}-${end}`,
);
if (
response.status !== 206 ||
response.headers.get('content-range') !==
`bytes ${offset}-${end}/${artifact.byte_length}`
) {
await response.body?.cancel();
throw new Error('Artifact response does not match the requested range');
}
const bytes = new Uint8Array(end - offset + 1);
const reader = response.body?.getReader();
if (!reader) throw new Error('Artifact response has no byte stream');
let received = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
if (received + chunk.value.byteLength > bytes.byteLength) {
throw new Error('Artifact response exceeds the requested range');
}
bytes.set(chunk.value, received);
received += chunk.value.byteLength;
}
if (received !== bytes.byteLength) {
throw new Error('Artifact response ended before the requested range');
}
return bytes;
} finally {
await reader.cancel().catch(() => undefined);
}
}
async openArtifactStream(
artifact: ManagedArtifact,
signal?: AbortSignal,
): Promise<ReadableStream<Uint8Array>> {
const response = await this.artifactContent(artifact, signal);
if (
response.status !== 200 ||
response.headers.get('content-length') !== String(artifact.byte_length) ||
!response.body
) {
await response.body?.cancel();
throw new Error('Artifact download does not match its metadata');
}
let received = 0;
return response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
received += chunk.byteLength;
if (received > artifact.byte_length) {
throw new Error('Artifact download exceeds its declared length');
}
controller.enqueue(chunk);
},
flush() {
if (received !== artifact.byte_length) {
throw new Error('Artifact download is incomplete');
}
},
}),
{ signal },
);
}
private async artifactContent(
artifact: ManagedArtifact,
signal?: AbortSignal,
range?: string,
): Promise<Response> {
if (
!/^[a-f0-9]{64}$/.test(artifact.revision) ||
artifact.sha256 !== artifact.revision ||
!Number.isSafeInteger(artifact.byte_length) ||
artifact.byte_length < 0
) {
throw new Error('Artifact byte identity is invalid');
}
const headers = new Headers(await this.options.getHeaders?.());
headers.set('accept', 'application/octet-stream');
headers.set('if-match', `"${artifact.sha256}"`);
if (range) headers.set('range', range);
const path = `/v1/agents/sessions/${encodeURIComponent(artifact.session_id)}/artifacts/${encodeURIComponent(artifact.id)}/content`;
let response: Response;
for (let attempt = 0; ; attempt++) {
response = await this.fetchImpl(
`${this.baseUrl}${path}?revision=${encodeURIComponent(artifact.revision)}`,
{
method: 'GET',
headers,
credentials: this.credentials,
signal,
redirect: 'error',
},
);
if (attempt > 0 || ![429, 503].includes(response.status)) break;
const retryAfter = response.headers.get('retry-after');
const delay =
retryAfter === null
? 1000
: Number.isFinite(Number(retryAfter))
? Number(retryAfter) * 1000
: Date.parse(retryAfter) - Date.now();
const wait = Number.isFinite(delay) ? Math.max(0, delay) : 1000;
if (wait > 5000) break;
await response.body?.cancel();
signal?.throwIfAborted();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, wait);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
if (!response.ok) throw await toHttpError(response);
if (
response.headers.get('etag') !== `"${artifact.sha256}"` ||
![null, 'identity'].includes(response.headers.get('content-encoding'))
) {
await response.body?.cancel();
throw new Error('Artifact validator does not match its metadata');
}
return response;
}
async *streamEvents(
request: Schemas['WebShellStreamRequest'],
signal?: AbortSignal,
onOpen?: () => void,
onAlive?: () => void,
): AsyncGenerator<JavaAgentEvent | JavaAgentResyncRequired> {
const response = await this.request(
'/events/stream',
request,
signal,
true,
);
if (!response.body) {
throw new JavaManagedAgentHttpError(
response.status,
'agent_api_stream_unavailable',
'Managed Agent event stream is unavailable',
);
}
// The response headers are back and the body is attached: the stream
// is established even when it then idles on heartbeats, which never
// decode into events.
onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let consecutiveCorrupt = 0;
let skippedTotal = 0;
let yieldedAny = false;
// The fields are informational and never read, hence the placeholders.
const resyncFrame = (): JavaAgentResyncRequired => ({
type: RESYNC_REQUIRED,
sessionId: request.sessionId,
replayFloorSequence: 0,
snapshotThroughSequence: 0,
action: 'reload_snapshot',
});
const decodeFrame = (
frame: string,
trailing = false,
): JavaAgentEvent | JavaAgentResyncRequired | undefined => {
let event: JavaAgentEvent | JavaAgentResyncRequired | undefined;
let failure: unknown;
try {
event = decodeEventFrame(frame);
} catch (error) {
failure = error;
}
if (event) {
consecutiveCorrupt = 0;
return event;
}
const { data, id, name } = parseEventFrameFields(frame);
if (data.length === 0) {
const hasContent = frame
.split(/\r?\n/)
.some((line) => line && !line.startsWith(':'));
if (!hasContent) {
// A complete heartbeat or comment frame is the server answering:
// on an idle Session it is the only sign of life the stream
// gives. A trailing fragment is a mid-frame disconnect, never a
// complete frame, so it does not count.
if (!trailing) onAlive?.();
return undefined;
}
if (!trailing) {
// A mid-stream frame with id/event lines but no data line is
// malformed server output, not a heartbeat: count it as corrupt
// below instead of dropping it silently.
failure ??= new Error('frame has no data payload');
}
}
if (trailing) {
// A leftover buffer at end of stream is a mid-frame disconnect, not
// a corrupt persisted frame: log it as such and spare the budget.
console.warn(
`[web-shell] the Managed Agent event stream closed mid-frame; the frame at id ${id ?? 'none'} was discarded`,
);
return undefined;
}
// Corrupt frame: unparseable JSON, a payload that is not an event
// object, or a mid-stream frame with no data line. Skip it and let
// later frames move the consumer's cursor past it — except the shapes
// a skip cannot recover from, which resync.
consecutiveCorrupt += 1;
if (
// A run of corrupt frames means the log itself is poisoned: resync
// past the whole run instead of skipping forever. (The id-less
// resync marker is covered by the fail-closed rule too.)
consecutiveCorrupt > MAX_CONSECUTIVE_CORRUPT_FRAMES ||
// Fail closed: only delta text the snapshot re-assembles may skip.
!SKIP_ON_CORRUPT.has(name ?? '')
) {
console.warn(
`[web-shell] resyncing on a corrupt Managed Agent frame (id: ${id ?? 'none'}, event: ${name ?? 'unknown'}):`,
failure ?? 'non-event payload',
);
consecutiveCorrupt = 0;
return resyncFrame();
}
skippedTotal += 1;
if (skippedTotal === 1 || skippedTotal % SKIP_LOG_EVERY === 0) {
console.warn(
`[web-shell] skipping a corrupt Managed Agent event frame (id: ${id ?? 'none'}, event: ${name ?? 'unknown'}):`,
failure ?? 'non-event payload',
);
}
return undefined;
};
try {
while (true) {
const result = await reader.read();
buffer += decoder.decode(result.value, { stream: !result.done });
let boundary = nextFrameBoundary(buffer);
while (boundary) {
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const event = decodeFrame(frame);
if (event) {
yieldedAny = true;
yield event;
}
boundary = nextFrameBoundary(buffer);
}
if (result.done) break;
}
const event = decodeFrame(buffer, true);
if (event) {
yieldedAny = true;
yield event;
}
// A connection that delivered nothing but skips means the rest of the
// replay log is undeliverable: end with a resync so the consumer
// reloads the durable transcript instead of retrying the same cursor.
if (!yieldedAny && skippedTotal > 0) {
console.warn(
`[web-shell] resyncing a Managed Agent stream that delivered only corrupt frames (${skippedTotal} skipped)`,
);
yield resyncFrame();
}
} finally {
await reader.cancel().catch(() => undefined);
}
}
queryUserMessages(sessionId: string, signal?: AbortSignal) {
return this.post<
import('./use-managed-user-messages').ManagedUserMessageSnapshot
>('/user-messages/query', { sessionId }, signal);
}
commandUserMessages(
sessionId: string,
command: import('./use-managed-user-messages').ManagedUserMessageCommand,
idempotencyKey: string,
signal?: AbortSignal,
) {
return this.post<
import('./use-managed-user-messages').ManagedUserMessageSnapshot
>('/user-messages/command', { sessionId, command, idempotencyKey }, signal);
}
private async post<T>(
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<T> {
const response = await this.request(path, body, signal, false);
return (await response.json()) as T;
}
private async request(
path: string,
body: unknown,
signal: AbortSignal | undefined,
stream: boolean,
): Promise<Response> {
return this.fetchRequest(
path,
JSON.stringify(body),
signal,
stream,
'application/json',
);
}
private async fetchRequest(
path: string,
body: BodyInit,
signal: AbortSignal | undefined,
stream: boolean,
contentType: string,
): Promise<Response> {
const supplied = await this.options.getHeaders?.();
const headers = new Headers(supplied);
if (
contentType === 'application/octet-stream' ||
!headers.has('content-type')
) {
headers.set('content-type', contentType);
}
headers.set('accept', stream ? 'text/event-stream' : 'application/json');
const response = await this.fetchImpl(
`${this.baseUrl}${API_PREFIX}${path}`,
{
method: 'POST',
headers,
credentials: this.credentials,
body,
signal,
},
);
if (!response.ok) {
throw await toHttpError(response);
}
return response;
}
}
function nextFrameBoundary(
value: string,
): { index: number; length: number } | undefined {
const match = /\r?\n\r?\n/.exec(value);
return match?.index === undefined
? undefined
: { index: match.index, length: match[0].length };
}
function parseEventFrameFields(frame: string): {
data: string[];
id?: number;
name?: string;
} {
const data: string[] = [];
let id: number | undefined;
let name: string | undefined;
for (const line of frame.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue;
const separator = line.indexOf(':');
const field = separator < 0 ? line : line.slice(0, separator);
const value =
separator < 0 ? '' : line.slice(separator + 1).replace(/^ /, '');
if (field === 'data') data.push(value);
if (field === 'id' && /^\d+$/.test(value)) id = Number(value);
if (field === 'event') name = value;
}
return { data, id, name };
}
function decodeEventFrame(
frame: string,
): JavaAgentEvent | JavaAgentResyncRequired | undefined {
const { data, id, name } = parseEventFrameFields(frame);
if (data.length === 0) return undefined;
const parsed: unknown = JSON.parse(data.join('\n'));
// Both frame shapes this stream carries are objects with a string `type`
// (WebShellEvent and WebShellResyncRequired); anything else is corrupt.
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as { type?: unknown }).type !== 'string'
)
return undefined;
// The server's only frame without an id: the cursor fell below the replay
// floor, and the stream ends after it.
if (name === RESYNC_REQUIRED && id === undefined) {
return parsed as JavaAgentResyncRequired;
}
const event = parsed as JavaAgentEvent;
return id === undefined || event.sequence === id
? event
: { ...event, sequence: id };
}
async function toHttpError(
response: Response,
): Promise<JavaManagedAgentHttpError> {
let code = `http_${response.status}`;
let message = `Managed Agent request failed (${response.status})`;
let requestId = response.headers.get('x-request-id')?.trim() || undefined;
try {
const payload: unknown = await response.json();
if (typeof payload === 'object' && payload !== null) {
const error = (payload as Record<string, unknown>)['error'];
if (typeof error === 'object' && error !== null) {
const fields = error as Record<string, unknown>;
if (typeof fields['code'] === 'string') code = fields['code'];
if (typeof fields['message'] === 'string') message = fields['message'];
if (typeof fields['request_id'] === 'string')
requestId = fields['request_id'].trim() || undefined;
}
}
} catch {
// Preserve the stable HTTP fallback when the response is not JSON.
}
return new JavaManagedAgentHttpError(
response.status,
code,
message,
requestId,
);
}