Skip to content
Merged
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
73 changes: 58 additions & 15 deletions builds/typescript/client_web/src/api/gateway-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import {
type ChatEvent,
} from "./gateway-adapter";

function sseResponse(frames: string): Response {
return new Response(frames, {
status: 200,
headers: {
"content-type": "text/event-stream",
},
});
}
function sseResponse(frames: string, headers?: Record<string, string>): Response {
return new Response(frames, {
status: 200,
headers: {
"content-type": "text/event-stream",
...(headers ?? {}),
},
});
}

async function collectEvents(stream: AsyncIterable<ChatEvent>): Promise<ChatEvent[]> {
const events: ChatEvent[] = [];
Expand Down Expand Up @@ -67,7 +68,7 @@ describe("gateway-adapter SSE parsing", () => {
]);
});

it("maps legacy text-delta content field to delta", async () => {
it("maps legacy text-delta content field to delta", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Expand All @@ -85,12 +86,54 @@ describe("gateway-adapter SSE parsing", () => {
);

const events = await collectEvents(sendMessage(null, "hi"));
expect(events[0]).toMatchObject({
type: "text-delta",
delta: "Legacy format",
});
});
});
expect(events[0]).toMatchObject({
type: "text-delta",
delta: "Legacy format",
});
});

it("exposes context-window warnings from response headers", async () => {
const onContextWarning = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async () =>
sseResponse(
[
"event: done",
'data: {"finish_reason":"stop","conversation_id":"conv_3"}',
"",
].join("\n"),
{
"x-context-window-warning": "1",
"x-context-window-estimated-tokens": "90000",
"x-context-window-budget-tokens": "100000",
"x-context-window-ratio": "0.9",
"x-context-window-threshold": "0.8",
"x-context-window-managed": "1",
"x-context-window-message": "This session is getting long.",
}
)
)
);

const events = await collectEvents(sendMessage(null, "hi", { onContextWarning }));
expect(events).toEqual([
{
type: "done",
finish_reason: "stop",
conversation_id: "conv_3",
},
]);
expect(onContextWarning).toHaveBeenCalledWith({
estimated_tokens: 90000,
budget_tokens: 100000,
ratio: 0.9,
threshold: 0.8,
managed: true,
message: "This session is getting long.",
});
});
});

describe("gateway-adapter settings models", () => {
beforeEach(() => {
Expand Down
71 changes: 52 additions & 19 deletions builds/typescript/client_web/src/api/gateway-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { parseSSE } from "./sse-parser";
import {
GatewayError,
GatewayNotFoundError,
type ApprovalDecision,
type ChatEvent,
type Conversation,
type ConversationDetail,
type ApprovalDecision,
type ChatEvent,
type Conversation,
type ConversationDetail,
type ContextWindowWarning,
type GatewayCredentialUpdateRequest,
type GatewayCredentialUpdateResponse,
type GatewayMemoryBackupRestoreRequest,
Expand Down Expand Up @@ -36,10 +37,11 @@ type ConversationListResponse = {
offset: number;
};

type SendMessageOptions = {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
};
type SendMessageOptions = {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onContextWarning?: (warning: ContextWindowWarning) => void;
};

type ErrorPayload = {
code?: string;
Expand Down Expand Up @@ -188,7 +190,7 @@ function toChatEvent(eventName: string, data: string): ChatEvent {
return normalized;
}

function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown {
function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown {
if (!isRecord(parsed)) {
return parsed;
}
Expand Down Expand Up @@ -216,8 +218,34 @@ function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown
};
}

return withType;
}
return withType;
}

function parseContextWindowWarning(headers: Headers): ContextWindowWarning | null {
if (headers.get("x-context-window-warning") !== "1") {
return null;
}

const estimatedTokens = Number.parseInt(headers.get("x-context-window-estimated-tokens") ?? "", 10);
const budgetTokens = Number.parseInt(headers.get("x-context-window-budget-tokens") ?? "", 10);
const ratio = Number.parseFloat(headers.get("x-context-window-ratio") ?? "");
const threshold = Number.parseFloat(headers.get("x-context-window-threshold") ?? "");
const managed = headers.get("x-context-window-managed") === "1";
const message = headers.get("x-context-window-message") ?? "This session is getting long.";

if (!Number.isFinite(estimatedTokens) || !Number.isFinite(budgetTokens) || !Number.isFinite(ratio)) {
return null;
}

return {
estimated_tokens: estimatedTokens,
budget_tokens: budgetTokens,
ratio,
threshold: Number.isFinite(threshold) ? threshold : 0.8,
managed,
message,
};
}

export async function* sendMessage(
conversationId: string | null,
Expand All @@ -241,14 +269,19 @@ export async function* sendMessage(
signal: options.signal
});

if (!response.ok) {
throw await toGatewayError(response);
}

for await (const event of parseSSE(response)) {
yield toChatEvent(event.event, event.data);
}
}
if (!response.ok) {
throw await toGatewayError(response);
}

const contextWarning = parseContextWindowWarning(response.headers);
if (contextWarning) {
options.onContextWarning?.(contextWarning);
}

for await (const event of parseSSE(response)) {
yield toChatEvent(event.event, event.data);
}
}

export async function listConversations(): Promise<Conversation[]> {
const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/conversations`, {
Expand Down
9 changes: 9 additions & 0 deletions builds/typescript/client_web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export type ChatEvent =
| ChatErrorEvent
| DoneEvent;

export type ContextWindowWarning = {
estimated_tokens: number;
budget_tokens: number;
ratio: number;
threshold: number;
managed: boolean;
message: string;
};

export type ApprovalDecision = "approved" | "denied";
export type ApprovalMode = "ask-on-write" | "auto-approve";

Expand Down
90 changes: 88 additions & 2 deletions builds/typescript/client_web/src/api/useGatewayChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@ const sendMessageMock = vi.fn<
(
conversationId: string | null,
content: string,
options?: { signal?: AbortSignal; metadata?: Record<string, unknown> }
options?: {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onContextWarning?: (warning: {
estimated_tokens: number;
budget_tokens: number;
ratio: number;
threshold: number;
managed: boolean;
message: string;
}) => void;
}
) => AsyncIterable<ChatEvent>
>();

Expand Down Expand Up @@ -41,7 +52,18 @@ vi.mock("./gateway-adapter", () => ({
sendMessage: (
conversationId: string | null,
content: string,
options?: { signal?: AbortSignal; metadata?: Record<string, unknown> }
options?: {
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onContextWarning?: (warning: {
estimated_tokens: number;
budget_tokens: number;
ratio: number;
threshold: number;
managed: boolean;
message: string;
}) => void;
}
) => sendMessageMock(conversationId, content, options),
submitApprovalDecision: (requestId: string, decision: "approved" | "denied") =>
submitApprovalDecisionMock(requestId, decision),
Expand Down Expand Up @@ -160,11 +182,75 @@ describe("useGatewayChat", () => {
});

expect(result.current.error?.message).toBe("Provider unavailable");
expect(result.current.errorCode).toBe("provider_error");
expect(result.current.messages).toEqual([
{ id: "message-1", role: "user", content: "Hello" }
]);
});

it("stores context overflow error code for overflow-specific UI actions", async () => {
sendMessageMock.mockImplementation(() =>
streamEvents([
{
type: "error",
code: "context_overflow",
message: "This session has gotten long. Start a new conversation to continue - all your work is saved.",
},
])
);

const { result } = renderHook(() => useGatewayChat());

act(() => {
result.current.append("Hello");
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.errorCode).toBe("context_overflow");
});

it("stores context warning metadata passed from the gateway adapter", async () => {
sendMessageMock.mockImplementation((_conversationId, _content, options) =>
(async function* contextWarningStream() {
options?.onContextWarning?.({
estimated_tokens: 90_000,
budget_tokens: 100_000,
ratio: 0.9,
threshold: 0.8,
managed: true,
message: "This session is getting long. Earlier turns were compacted so you can keep chatting.",
});
yield {
type: "done",
finish_reason: "stop",
conversation_id: "conv-warning",
} as ChatEvent;
})()
);

const { result } = renderHook(() => useGatewayChat());

act(() => {
result.current.append("Hello");
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.contextWindowWarning).toEqual({
estimated_tokens: 90_000,
budget_tokens: 100_000,
ratio: 0.9,
threshold: 0.8,
managed: true,
message: "This session is getting long. Earlier turns were compacted so you can keep chatting.",
});
});

it("auto-approves approval requests during streaming", async () => {
sendMessageMock.mockImplementation(() =>
streamEvents([
Expand Down
Loading