chore(merge): resolve dev conflicts for pr 537

- merge latest origin/dev into feat/506-composite-provider-variant

- keep path-stable test assertions for update-checker and GLMT debug logs
This commit is contained in:
Tam Nhu Tran
2026-02-13 08:36:10 +07:00
20 changed files with 845 additions and 261 deletions
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+4 -4
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
# Agent Guidelines
AI-facing guidance for Claude Code when working with this repository.
AI-facing guidance for agent tooling when working with this repository.
## Critical Constraints (NEVER VIOLATE)
@@ -17,7 +17,7 @@ Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()`
## Core Function
CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation.
CLI wrapper for instant switching between multiple provider accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation.
## Design Principles (ENFORCE STRICTLY)
@@ -113,7 +113,7 @@ bun run validate # Step 3: Final check (must pass)
- **Scope:** CCS CLI terminal output (`src/` code that prints to stdout/stderr)
- **Does NOT apply to:** PR descriptions, commit messages, documentation, comments, AI conversations
2. **TTY-aware colors** - Respect NO_COLOR env var
3. **Non-invasive** - NEVER modify `~/.claude/settings.json` without explicit user request and confirmation (exception: `ccs persist` command)
3. **Non-invasive** - NEVER modify external tool settings (`~/.claude/settings.json`) without explicit user request and confirmation (exception: `ccs persist` command)
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below)
6. **Idempotent** - All install operations safe to run multiple times
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.43.0-dev.5",
"version": "7.43.0-dev.8",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+307 -47
View File
@@ -4,13 +4,12 @@
*/
import * as crypto from 'crypto';
import * as zlib from 'zlib';
import type { IncomingHttpHeaders } from 'http';
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
import { buildCursorRequest } from './cursor-translator.js';
import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js';
/** Executor parameters */
interface ExecutorParams {
@@ -57,42 +56,6 @@ async function getHttp2() {
}
}
/**
* Decompress payload if needed
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
*/
function decompressPayload(payload: Buffer, flags: number): Buffer {
// Check if payload is JSON error
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) {
return payload;
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] JSON error detection failed:', err);
}
}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return Buffer.alloc(0);
}
}
return payload;
}
/**
* Create error response from JSON error
*/
@@ -342,6 +305,20 @@ export class CursorExecutor {
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
// Streaming requests use incremental HTTP/2 → SSE pipeline
if (stream) {
const response = await this.executeStreaming(
url,
headers,
transformedBody,
model,
body,
signal
);
return { response, url, headers, transformedBody: body };
}
// Non-streaming: buffer entire response then transform to JSON
const http2 = await getHttp2();
const response = http2
? await this.makeHttp2Request(url, headers, transformedBody, signal)
@@ -365,11 +342,7 @@ export class CursorExecutor {
return { response: errorResponse, url, headers, transformedBody: body };
}
const transformedResponse =
stream === true
? this.transformProtobufToSSE(response.body, model, body)
: this.transformProtobufToJSON(response.body, model, body);
const transformedResponse = this.transformProtobufToJSON(response.body, model, body);
return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) {
const errorResponse = new Response(
@@ -389,6 +362,296 @@ export class CursorExecutor {
}
}
/**
* Execute a streaming request, piping HTTP/2 data events through
* StreamingFrameParser for incremental SSE output.
* Falls back to buffered transformProtobufToSSE when http2 is unavailable.
*/
private async executeStreaming(
url: string,
headers: Record<string, string>,
body: Uint8Array,
model: string,
requestBody: ExecutorParams['body'],
signal?: AbortSignal
): Promise<Response> {
const http2 = await getHttp2();
if (!http2) {
const response = await this.makeFetchRequest(url, headers, body, signal);
if (response.status !== 200) {
const errorText = response.body?.toString() || 'Unknown error';
return new Response(
JSON.stringify({
error: {
message: `[${response.status}]: ${errorText}`,
type: response.status === 429 ? 'rate_limit_error' : 'invalid_request_error',
code: '',
},
}),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
}
return this.transformProtobufToSSE(response.body, model, requestBody);
}
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
client.on('error', (err) => {
client.close();
reject(err);
});
const req = client.request({
':method': 'POST',
':path': urlObj.pathname,
':authority': urlObj.host,
':scheme': 'https',
...headers,
});
// Register abort before response headers arrive so cancellation works at all stages
let streamClosed = false;
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
if (signal) {
const onAbort = () => {
streamClosed = true;
// Close the ReadableStream controller so consumers don't hang on reader.read()
if (streamController) {
try {
streamController.close();
} catch {
/* already closed */
}
}
req.close();
client.close();
};
signal.addEventListener('abort', onAbort, { once: true });
const cleanup = () => signal.removeEventListener('abort', onAbort);
req.on('end', cleanup);
req.on('error', cleanup);
}
req.on('response', (hdrs) => {
const status = Number(hdrs[':status']) || 500;
if (status !== 200) {
const errorChunks: Buffer[] = [];
req.on('data', (c: Buffer) => errorChunks.push(c));
req.on('end', () => {
client.close();
const errorText = Buffer.concat(errorChunks).toString();
resolve(
new Response(
JSON.stringify({
error: {
message: `[${status}]: ${errorText}`,
type: 'invalid_request_error',
code: '',
},
}),
{ status, headers: { 'Content-Type': 'application/json' } }
)
);
});
return;
}
// Status 200: set up incremental streaming pipeline
const parser = new StreamingFrameParser();
const enc = new TextEncoder();
const toolCallsMap = new Map<
string,
{
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
index: number;
}
>();
let chunkCount = 0;
let toolCallCount = 0;
const readable = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
const emitSSE = (data: string) => {
if (streamClosed) return;
controller.enqueue(enc.encode(`data: ${data}\n\n`));
};
const closeStream = () => {
if (streamClosed) return;
streamClosed = true;
try {
controller.close();
} catch {
/* already closed */
}
client.close();
};
const buildChunk = (delta: Record<string, unknown>, finishReason: string | null) =>
JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
});
req.on('data', (chunk: Buffer) => {
if (streamClosed) return;
for (const frame of parser.push(chunk)) {
if (frame.type === 'error') {
emitSSE(
JSON.stringify({
error: { message: frame.message, type: frame.errorType, code: '' },
})
);
emitSSE('[DONE]');
closeStream();
return;
}
if (frame.type === 'toolCall') {
const tc = frame.toolCall;
// Emit role chunk on first content
if (chunkCount === 0) {
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
chunkCount++;
}
if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
if (tc.function.arguments) {
emitSSE(
buildChunk(
{
tool_calls: [
{
index: existing.index,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
null
)
);
chunkCount++;
}
} else {
const idx = toolCallCount++;
toolCallsMap.set(tc.id, { ...tc, index: idx });
emitSSE(
buildChunk(
{
tool_calls: [
{
index: idx,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
null
)
);
chunkCount++;
}
}
if (frame.type === 'text') {
const delta =
chunkCount === 0 && toolCallCount === 0
? { role: 'assistant', content: frame.text }
: { content: frame.text };
emitSSE(buildChunk(delta, null));
chunkCount++;
}
}
});
req.on('end', () => {
if (streamClosed) return;
if (chunkCount === 0 && toolCallCount === 0) {
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
}
emitSSE(
JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop',
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
})
);
emitSSE('[DONE]');
closeStream();
});
req.on('error', (err) => {
if (!streamClosed) {
try {
controller.error(err);
} catch {
/* already closed */
}
}
client.close();
});
},
});
resolve(
new Response(readable, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
);
});
req.on('error', (err) => {
client.close();
reject(err);
});
req.write(body);
req.end();
});
}
/**
* Parse protobuf buffer into frames and extract text/toolcalls.
* Shared logic between JSON and SSE transformers.
@@ -590,11 +853,8 @@ export class CursorExecutor {
});
}
/** @deprecated Use executeStreaming() for real-time SSE. Retained for fetch fallback. */
transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
// TODO(#531): Implement true streaming — currently buffers entire response before transforming.
// This should pipe HTTP/2 data events through a TransformStream for incremental SSE output.
// See: https://github.com/kaitranntt/ccs/issues/531
// NOTE: Chunk boundary splits may emit duplicate SSE messages if a frame spans multiple chunks.
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
+26 -26
View File
@@ -160,8 +160,8 @@ function extractToolCall(toolCallData: Uint8Array): {
let isLast = false;
// Extract tool call ID
if (toolCall.has(FIELD.TOOL_ID)) {
const idField = toolCall.get(FIELD.TOOL_ID);
if (toolCall.has(FIELD.ToolCall.ID)) {
const idField = toolCall.get(FIELD.ToolCall.ID);
if (idField && idField[0]) {
const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
toolCallId = fullId.split('\n')[0]; // Take first line
@@ -169,44 +169,44 @@ function extractToolCall(toolCallData: Uint8Array): {
}
// Extract tool name
if (toolCall.has(FIELD.TOOL_NAME)) {
const nameField = toolCall.get(FIELD.TOOL_NAME);
if (toolCall.has(FIELD.ToolCall.NAME)) {
const nameField = toolCall.get(FIELD.ToolCall.NAME);
if (nameField && nameField[0]) {
toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
}
}
// Extract is_last flag
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
const lastField = toolCall.get(FIELD.TOOL_IS_LAST);
if (toolCall.has(FIELD.ToolCall.IS_LAST)) {
const lastField = toolCall.get(FIELD.ToolCall.IS_LAST);
if (lastField && lastField[0]) {
isLast = (lastField[0].value as number) !== 0;
}
}
// Extract MCP params - nested real tool info
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
if (toolCall.has(FIELD.ToolCall.MCP_PARAMS)) {
try {
const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS);
const mcpField = toolCall.get(FIELD.ToolCall.MCP_PARAMS);
if (!mcpField || !mcpField[0]) return null;
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST);
if (mcpParams.has(FIELD.McpParams.TOOLS_LIST)) {
const toolsList = mcpParams.get(FIELD.McpParams.TOOLS_LIST);
if (!toolsList || !toolsList[0]) return null;
const tool = decodeMessage(toolsList[0].value as Uint8Array);
if (tool.has(FIELD.MCP_NESTED_NAME)) {
const nestedName = tool.get(FIELD.MCP_NESTED_NAME);
if (tool.has(FIELD.McpNested.NAME)) {
const nestedName = tool.get(FIELD.McpNested.NAME);
if (nestedName && nestedName[0]) {
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
}
}
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS);
if (tool.has(FIELD.McpNested.PARAMS)) {
const nestedParams = tool.get(FIELD.McpNested.PARAMS);
if (nestedParams && nestedParams[0]) {
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
}
@@ -221,8 +221,8 @@ function extractToolCall(toolCallData: Uint8Array): {
}
// Fallback to raw_args
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS);
if (!rawArgs && toolCall.has(FIELD.ToolCall.RAW_ARGS)) {
const rawArgsField = toolCall.get(FIELD.ToolCall.RAW_ARGS);
if (rawArgsField && rawArgsField[0]) {
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
}
@@ -255,21 +255,21 @@ function extractTextAndThinking(responseData: Uint8Array): {
let thinking: string | null = null;
// Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) {
const textField = nested.get(FIELD.RESPONSE_TEXT);
if (nested.has(FIELD.ChatResponse.TEXT)) {
const textField = nested.get(FIELD.ChatResponse.TEXT);
if (textField && textField[0]) {
text = new TextDecoder().decode(textField[0].value as Uint8Array);
}
}
// Extract thinking
if (nested.has(FIELD.THINKING)) {
if (nested.has(FIELD.ChatResponse.THINKING)) {
try {
const thinkingField = nested.get(FIELD.THINKING);
const thinkingField = nested.get(FIELD.ChatResponse.THINKING);
if (thinkingField && thinkingField[0]) {
const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
const thinkingTextField = thinkingMsg.get(FIELD.THINKING_TEXT);
if (thinkingMsg.has(FIELD.Thinking.TEXT)) {
const thinkingTextField = thinkingMsg.get(FIELD.Thinking.TEXT);
if (thinkingTextField && thinkingTextField[0]) {
thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
}
@@ -304,8 +304,8 @@ export function extractTextFromResponse(payload: Uint8Array): {
const fields = decodeMessage(payload);
// Field 1: ClientSideToolV2Call
if (fields.has(FIELD.TOOL_CALL)) {
const toolCallField = fields.get(FIELD.TOOL_CALL);
if (fields.has(FIELD.Response.TOOL_CALL)) {
const toolCallField = fields.get(FIELD.Response.TOOL_CALL);
if (toolCallField && toolCallField[0]) {
const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
if (toolCall) {
@@ -315,8 +315,8 @@ export function extractTextFromResponse(payload: Uint8Array): {
}
// Field 2: StreamUnifiedChatResponse
if (fields.has(FIELD.RESPONSE)) {
const responseField = fields.get(FIELD.RESPONSE);
if (fields.has(FIELD.Response.RESPONSE)) {
const responseField = fields.get(FIELD.Response.RESPONSE);
if (responseField && responseField[0]) {
const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
+33 -33
View File
@@ -84,10 +84,10 @@ export function encodeToolResult(toolResult: CursorToolResult): Uint8Array {
const rawArgs = toolResult.raw_args || '{}';
return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
encodeField(FIELD.ToolResult.CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.ToolResult.NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.ToolResult.INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.ToolResult.RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
);
}
@@ -103,22 +103,22 @@ export function encodeMessage(
toolResults: CursorToolResult[]
): Uint8Array {
return concatArrays(
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
encodeField(FIELD.Message.CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.Message.ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.Message.ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0
? toolResults.map((tr) =>
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
encodeField(FIELD.Message.TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
)
: []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField(FIELD.Message.IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField(
FIELD.MSG_UNIFIED_MODE,
FIELD.Message.UNIFIED_MODE,
WIRE_TYPE.VARINT,
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
...(isLast && hasTools
? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
? [encodeField(FIELD.Message.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [])
);
}
@@ -127,7 +127,7 @@ export function encodeMessage(
* Encode instruction text
*/
export function encodeInstruction(text: string): Uint8Array {
return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
return text ? encodeField(FIELD.Instruction.TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
}
/**
@@ -135,8 +135,8 @@ export function encodeInstruction(text: string): Uint8Array {
*/
export function encodeModel(modelName: string): Uint8Array {
return concatArrays(
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
encodeField(FIELD.Model.NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.Model.EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
);
}
@@ -145,16 +145,16 @@ export function encodeModel(modelName: string): Uint8Array {
*/
export function encodeCursorSetting(): Uint8Array {
const unknown6 = concatArrays(
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
encodeField(FIELD.Setting.U6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.Setting.U6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
);
return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1)
encodeField(FIELD.Setting.PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'),
encodeField(FIELD.Setting.UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.Setting.UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.Setting.UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Setting.UNKNOWN_9, WIRE_TYPE.VARINT, 1)
);
}
@@ -163,11 +163,11 @@ export function encodeCursorSetting(): Uint8Array {
*/
export function encodeMetadata(): Uint8Array {
return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || 'x64'),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || '/'),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
encodeField(FIELD.Metadata.PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'),
encodeField(FIELD.Metadata.ARCH, WIRE_TYPE.LEN, process.arch || 'x64'),
encodeField(FIELD.Metadata.VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'),
encodeField(FIELD.Metadata.CWD, WIRE_TYPE.LEN, process.cwd() || '/'),
encodeField(FIELD.Metadata.TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
);
}
@@ -176,9 +176,9 @@ export function encodeMetadata(): Uint8Array {
*/
export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array {
return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
encodeField(FIELD.MessageId.ID, WIRE_TYPE.LEN, messageId),
...(summaryId ? [encodeField(FIELD.MessageId.SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
encodeField(FIELD.MessageId.ROLE, WIRE_TYPE.VARINT, role)
);
}
@@ -191,12 +191,12 @@ export function encodeMcpTool(tool: CursorTool): Uint8Array {
const inputSchema = tool.function?.parameters || tool.input_schema || {};
return concatArrays(
...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []),
...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []),
...(toolName ? [encodeField(FIELD.McpTool.NAME, WIRE_TYPE.LEN, toolName)] : []),
...(toolDesc ? [encodeField(FIELD.McpTool.DESC, WIRE_TYPE.LEN, toolDesc)] : []),
...(Object.keys(inputSchema).length > 0
? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
? [encodeField(FIELD.McpTool.PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
: []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, 'custom')
encodeField(FIELD.McpTool.SERVER, WIRE_TYPE.LEN, 'custom')
);
}
+80 -92
View File
@@ -30,113 +30,102 @@ export const THINKING_LEVEL = {
HIGH: 2,
} as const;
/** Field numbers for all protobuf messages */
/** Field numbers namespaced by protobuf message type */
export const FIELD = {
// ===== StreamUnifiedChatRequestWithTools (top level) =====
REQUEST: 1,
/** StreamUnifiedChatRequestWithTools (top level) */
Request: { REQUEST: 1 },
// ===== StreamUnifiedChatRequest =====
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
UNKNOWN_4: 4,
MODEL: 5,
WEB_TOOL: 8,
UNKNOWN_13: 13,
CURSOR_SETTING: 15,
UNKNOWN_19: 19,
CONVERSATION_ID: 23,
METADATA: 26,
IS_AGENTIC: 27,
SUPPORTED_TOOLS: 29,
MESSAGE_IDS: 30,
MCP_TOOLS: 34,
LARGE_CONTEXT: 35,
UNKNOWN_38: 38,
UNIFIED_MODE: 46,
UNKNOWN_47: 47,
SHOULD_DISABLE_TOOLS: 48,
THINKING_LEVEL: 49,
UNKNOWN_51: 51,
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
/** StreamUnifiedChatRequest */
Chat: {
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
UNKNOWN_4: 4,
MODEL: 5,
WEB_TOOL: 8,
UNKNOWN_13: 13,
CURSOR_SETTING: 15,
UNKNOWN_19: 19,
CONVERSATION_ID: 23,
METADATA: 26,
IS_AGENTIC: 27,
SUPPORTED_TOOLS: 29,
MESSAGE_IDS: 30,
MCP_TOOLS: 34,
LARGE_CONTEXT: 35,
UNKNOWN_38: 38,
UNIFIED_MODE: 46,
UNKNOWN_47: 47,
SHOULD_DISABLE_TOOLS: 48,
THINKING_LEVEL: 49,
UNKNOWN_51: 51,
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
},
// ===== ConversationMessage =====
MSG_CONTENT: 1,
MSG_ROLE: 2,
MSG_ID: 13,
MSG_TOOL_RESULTS: 18,
MSG_IS_AGENTIC: 29,
MSG_UNIFIED_MODE: 47,
MSG_SUPPORTED_TOOLS: 51,
/** ConversationMessage */
Message: {
CONTENT: 1,
ROLE: 2,
ID: 13,
TOOL_RESULTS: 18,
IS_AGENTIC: 29,
UNIFIED_MODE: 47,
SUPPORTED_TOOLS: 51,
},
// ===== ConversationMessage.ToolResult =====
TOOL_RESULT_CALL_ID: 1,
TOOL_RESULT_NAME: 2,
TOOL_RESULT_INDEX: 3,
TOOL_RESULT_RAW_ARGS: 5,
TOOL_RESULT_RESULT: 8, // Reserved for future tool result parsing
/** ConversationMessage.ToolResult */
ToolResult: {
CALL_ID: 1,
NAME: 2,
INDEX: 3,
RAW_ARGS: 5,
RESULT: 8,
},
// ===== Model =====
MODEL_NAME: 1,
MODEL_EMPTY: 4,
/** Model */
Model: { NAME: 1, EMPTY: 4 },
// ===== Instruction =====
INSTRUCTION_TEXT: 1,
/** Instruction */
Instruction: { TEXT: 1 },
// ===== CursorSetting =====
SETTING_PATH: 1,
SETTING_UNKNOWN_3: 3,
SETTING_UNKNOWN_6: 6,
SETTING_UNKNOWN_8: 8,
SETTING_UNKNOWN_9: 9,
/** CursorSetting (U6_FIELD_* are sub-fields of Unknown6 nested message) */
Setting: {
PATH: 1,
UNKNOWN_3: 3,
UNKNOWN_6: 6,
UNKNOWN_8: 8,
UNKNOWN_9: 9,
U6_FIELD_1: 1,
U6_FIELD_2: 2,
},
// ===== CursorSetting.Unknown6 =====
SETTING6_FIELD_1: 1,
SETTING6_FIELD_2: 2,
/** Metadata */
Metadata: { PLATFORM: 1, ARCH: 2, VERSION: 3, CWD: 4, TIMESTAMP: 5 },
// ===== Metadata =====
META_PLATFORM: 1,
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
/** MessageId */
MessageId: { ID: 1, SUMMARY: 2, ROLE: 3 },
// ===== MessageId =====
MSGID_ID: 1,
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
/** MCPTool */
McpTool: { NAME: 1, DESC: 2, PARAMS: 3, SERVER: 4 },
// ===== MCPTool =====
MCP_TOOL_NAME: 1,
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
/** StreamUnifiedChatResponseWithTools (response top-level) */
Response: { TOOL_CALL: 1, RESPONSE: 2 },
// ===== StreamUnifiedChatResponseWithTools (response) =====
TOOL_CALL: 1,
RESPONSE: 2,
/** ClientSideToolV2Call */
ToolCall: { ID: 3, NAME: 9, RAW_ARGS: 10, IS_LAST: 11, MCP_PARAMS: 27 },
// ===== ClientSideToolV2Call =====
TOOL_ID: 3,
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
/** MCPParams */
McpParams: { TOOLS_LIST: 1 },
// ===== MCPParams =====
MCP_TOOLS_LIST: 1,
/** MCPParams.Tool (nested) */
McpNested: { NAME: 1, PARAMS: 3 },
// ===== MCPParams.Tool (nested) =====
MCP_NESTED_NAME: 1,
MCP_NESTED_PARAMS: 3,
/** StreamUnifiedChatResponse */
ChatResponse: { TEXT: 1, THINKING: 25 },
// ===== StreamUnifiedChatResponse =====
RESPONSE_TEXT: 1,
THINKING: 25,
// ===== Thinking =====
THINKING_TEXT: 1,
/** Thinking */
Thinking: { TEXT: 1 },
} as const;
/** Type definitions */
@@ -144,7 +133,6 @@ export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE];
export type RoleType = (typeof ROLE)[keyof typeof ROLE];
export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
export type FieldNumber = (typeof FIELD)[keyof typeof FIELD];
/** Cursor credentials structure */
export interface CursorCredentials {
+25 -25
View File
@@ -82,55 +82,55 @@ export function encodeRequest(
// Build arrays for messages and tools
const messageFields = formattedMessages.map((fm) =>
encodeField(
FIELD.MESSAGES,
FIELD.Chat.MESSAGES,
WIRE_TYPE.LEN,
encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults)
)
);
const messageIdFields = messageIds.map((mid) =>
encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
encodeField(FIELD.Chat.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
);
const toolFields =
tools?.length > 0
? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
? tools.map((tool) => encodeField(FIELD.Chat.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
: [];
const supportedToolsField = isAgentic
? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
? [encodeField(FIELD.Chat.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [];
// Concatenate all parts
const parts: Uint8Array[] = [
...messageFields,
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ''),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()),
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
encodeField(FIELD.Chat.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Chat.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')),
encodeField(FIELD.Chat.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Chat.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.Chat.WEB_TOOL, WIRE_TYPE.LEN, ''),
encodeField(FIELD.Chat.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Chat.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.Chat.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Chat.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()),
encodeField(FIELD.Chat.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
encodeField(FIELD.Chat.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
...supportedToolsField,
...messageIdFields,
...toolFields,
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.Chat.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.Chat.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField(
FIELD.UNIFIED_MODE,
FIELD.Chat.UNIFIED_MODE,
WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ''),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'),
encodeField(FIELD.Chat.UNKNOWN_47, WIRE_TYPE.LEN, ''),
encodeField(FIELD.Chat.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.Chat.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.Chat.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.Chat.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.Chat.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'),
];
return concatArrays(...parts);
@@ -146,7 +146,7 @@ export function buildChatRequest(
reasoningEffort: string | null = null
): Uint8Array {
return encodeField(
FIELD.REQUEST,
FIELD.Request.REQUEST,
WIRE_TYPE.LEN,
encodeRequest(messages, modelName, tools, reasoningEffort)
);
+136
View File
@@ -0,0 +1,136 @@
/**
* Cursor Streaming Frame Parser
* Incrementally parses ConnectRPC frames from arbitrary chunk boundaries
*/
import * as zlib from 'zlib';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
import { extractTextFromResponse } from './cursor-protobuf-decoder.js';
/** Frame parsing result types */
export type FrameResult =
| { type: 'error'; message: string; status: number; errorType: string }
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| {
type: 'toolCall';
toolCall: {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
};
};
/**
* Decompress payload if gzip-compressed.
* Skips decompression for JSON error payloads.
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
*/
export function decompressPayload(payload: Buffer, flags: number): Buffer {
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) return payload;
} catch {
// Not JSON, continue
}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return Buffer.alloc(0);
}
}
return payload;
}
/**
* Incrementally parses ConnectRPC frames from arbitrary chunk boundaries.
*
* Usage:
* const parser = new StreamingFrameParser();
* req.on('data', (chunk) => {
* for (const frame of parser.push(chunk)) { handle(frame); }
* });
*/
export class StreamingFrameParser {
private buffer: Buffer = Buffer.alloc(0);
/** Feed a new chunk. Returns zero or more parsed frames. */
push(chunk: Buffer): FrameResult[] {
this.buffer = Buffer.concat([this.buffer, chunk]);
const results: FrameResult[] = [];
while (this.buffer.length >= 5) {
const length = this.buffer.readUInt32BE(1);
const frameSize = 5 + length;
if (this.buffer.length < frameSize) break;
const flags = this.buffer[0];
let payload = this.buffer.slice(5, frameSize);
this.buffer = this.buffer.slice(frameSize);
payload = decompressPayload(payload, flags);
// Check for JSON error
try {
const text = payload.toString('utf-8');
if (text.startsWith('{') && text.includes('"error"')) {
const json = JSON.parse(text);
const msg =
json?.error?.details?.[0]?.debug?.details?.title ||
json?.error?.details?.[0]?.debug?.details?.detail ||
json?.error?.message ||
'API Error';
const isRateLimit = json?.error?.code === 'resource_exhausted';
results.push({
type: 'error',
message: msg,
status: isRateLimit ? 429 : 400,
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
});
return results;
}
} catch {
// Not JSON, continue to protobuf parsing
}
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
const errorLower = result.error.toLowerCase();
const isRateLimit =
errorLower.includes('rate limit') ||
errorLower.includes('resource_exhausted') ||
errorLower.includes('too many requests');
results.push({
type: 'error',
message: result.error,
status: isRateLimit ? 429 : 400,
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
});
return results;
}
if (result.toolCall) results.push({ type: 'toolCall', toolCall: result.toolCall });
if (result.text) results.push({ type: 'text', text: result.text });
if (result.thinking) results.push({ type: 'thinking', text: result.thinking });
}
return results;
}
hasPartial(): boolean {
return this.buffer.length > 0;
}
}
+192 -6
View File
@@ -19,6 +19,7 @@ import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
import { generateCursorBody } from '../../../src/cursor/cursor-protobuf';
import { CursorExecutor } from '../../../src/cursor/cursor-executor';
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser';
describe('Protobuf Encoding/Decoding', () => {
describe('encodeVarint / decodeVarint round-trip', () => {
@@ -372,11 +373,11 @@ describe('Request Encoding', () => {
// Create two simple frames
const frame1 = wrapConnectRPCFrame(
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, 'Frame 1'),
encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, 'Frame 1'),
false
);
const frame2 = wrapConnectRPCFrame(
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, ' Frame 2'),
encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, ' Frame 2'),
false
);
@@ -497,8 +498,8 @@ describe('CursorExecutor', () => {
it('should handle basic text response', async () => {
// Create minimal protobuf response with text
const textContent = 'Hello, world!';
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
@@ -536,8 +537,8 @@ describe('CursorExecutor', () => {
it('should output SSE format', async () => {
// Create minimal protobuf response with text
const textContent = 'Hello';
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
@@ -655,3 +656,188 @@ describe('CursorExecutor', () => {
});
});
});
/**
* Helper: build a ConnectRPC frame from raw payload bytes
*/
function buildFrame(payload: Uint8Array, flags = 0): Buffer {
const header = Buffer.alloc(5);
header[0] = flags;
header.writeUInt32BE(payload.length, 1);
return Buffer.concat([header, Buffer.from(payload)]);
}
/**
* Helper: build a protobuf text response frame
*/
function buildTextFrame(text: string): Buffer {
const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, text);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
return buildFrame(responseMsg);
}
describe('StreamingFrameParser', () => {
it('should parse a complete single frame', () => {
const parser = new StreamingFrameParser();
const frame = buildTextFrame('Hello');
const results = parser.push(frame);
expect(results.length).toBe(1);
expect(results[0].type).toBe('text');
if (results[0].type === 'text') {
expect(results[0].text).toBe('Hello');
}
expect(parser.hasPartial()).toBe(false);
});
it('should buffer partial frame header (< 5 bytes)', () => {
const parser = new StreamingFrameParser();
const frame = buildTextFrame('Hi');
// Send only first 3 bytes (partial header)
const results1 = parser.push(frame.slice(0, 3));
expect(results1.length).toBe(0);
expect(parser.hasPartial()).toBe(true);
// Send rest of the frame
const results2 = parser.push(frame.slice(3));
expect(results2.length).toBe(1);
expect(results2[0].type).toBe('text');
expect(parser.hasPartial()).toBe(false);
});
it('should buffer partial frame payload', () => {
const parser = new StreamingFrameParser();
const frame = buildTextFrame('Hello world');
// Send header + partial payload
const splitPoint = 8; // past the 5-byte header but not full payload
const results1 = parser.push(frame.slice(0, splitPoint));
expect(results1.length).toBe(0);
expect(parser.hasPartial()).toBe(true);
// Complete the frame
const results2 = parser.push(frame.slice(splitPoint));
expect(results2.length).toBe(1);
expect(results2[0].type).toBe('text');
if (results2[0].type === 'text') {
expect(results2[0].text).toBe('Hello world');
}
});
it('should parse multiple frames from single chunk', () => {
const parser = new StreamingFrameParser();
const frame1 = buildTextFrame('First');
const frame2 = buildTextFrame('Second');
const combined = Buffer.concat([frame1, frame2]);
const results = parser.push(combined);
expect(results.length).toBe(2);
expect(results[0].type).toBe('text');
expect(results[1].type).toBe('text');
if (results[0].type === 'text' && results[1].type === 'text') {
expect(results[0].text).toBe('First');
expect(results[1].text).toBe('Second');
}
});
it('should handle frame split across two chunks', () => {
const parser = new StreamingFrameParser();
const frame1 = buildTextFrame('AAA');
const frame2 = buildTextFrame('BBB');
const combined = Buffer.concat([frame1, frame2]);
// Split in the middle of frame2
const splitPoint = frame1.length + 3;
const results1 = parser.push(combined.slice(0, splitPoint));
expect(results1.length).toBe(1); // frame1 complete
expect(results1[0].type).toBe('text');
const results2 = parser.push(combined.slice(splitPoint));
expect(results2.length).toBe(1); // frame2 complete
expect(results2[0].type).toBe('text');
if (results2[0].type === 'text') {
expect(results2[0].text).toBe('BBB');
}
});
it('should detect JSON error mid-stream', () => {
const parser = new StreamingFrameParser();
// First: a valid text frame
const textFrame = buildTextFrame('Before error');
const results1 = parser.push(textFrame);
expect(results1.length).toBe(1);
// Then: a JSON error frame
const errorJson = JSON.stringify({
error: { code: 'resource_exhausted', message: 'Rate limit' },
});
const errorFrame = buildFrame(new TextEncoder().encode(errorJson));
const results2 = parser.push(errorFrame);
expect(results2.length).toBe(1);
expect(results2[0].type).toBe('error');
if (results2[0].type === 'error') {
expect(results2[0].status).toBe(429);
expect(results2[0].errorType).toBe('rate_limit_error');
}
});
it('should report hasPartial() correctly', () => {
const parser = new StreamingFrameParser();
expect(parser.hasPartial()).toBe(false);
// Push partial data
parser.push(Buffer.from([0x00, 0x00]));
expect(parser.hasPartial()).toBe(true);
// Push enough to complete the frame (empty payload)
// header: flags=0, length=0 → 5 bytes total
const emptyFrame = Buffer.alloc(5);
emptyFrame[0] = 0;
emptyFrame.writeUInt32BE(0, 1);
const parser2 = new StreamingFrameParser();
parser2.push(emptyFrame);
expect(parser2.hasPartial()).toBe(false);
});
});
describe('decompressPayload', () => {
it('should pass through uncompressed payload', () => {
const payload = Buffer.from('raw protobuf data');
const result = decompressPayload(payload, 0x00);
expect(result).toEqual(payload);
});
it('should skip decompression for JSON error payloads', () => {
const errorPayload = Buffer.from('{"error":"something went wrong"}');
const result = decompressPayload(errorPayload, 0x01); // GZIP flag but JSON error
expect(result).toEqual(errorPayload);
});
it('should return empty buffer on invalid gzip data', () => {
const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]);
const result = decompressPayload(invalidGzip, 0x01);
expect(result.length).toBe(0);
});
it('should decompress valid gzip payload', () => {
const zlib = require('zlib');
const original = Buffer.from('Hello compressed world');
const compressed = zlib.gzipSync(original);
const result = decompressPayload(compressed, 0x01);
expect(result.toString()).toBe('Hello compressed world');
});
it('should handle GZIP_ALT and GZIP_BOTH flags', () => {
const zlib = require('zlib');
const original = Buffer.from('test data');
const compressed = zlib.gzipSync(original);
expect(decompressPayload(compressed, 0x02).toString()).toBe('test data');
expect(decompressPayload(compressed, 0x03).toString()).toBe('test data');
});
});
@@ -32,7 +32,7 @@ export function CliproxyStatsCard({
if (isLoading) {
return (
<Card className={cn('flex flex-col h-full', className)}>
<Card className={cn('flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0', className)}>
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
@@ -52,7 +52,12 @@ export function CliproxyStatsCard({
// Proxy not running
if (!status?.running) {
return (
<Card className={cn('flex flex-col h-full border-dashed', className)}>
<Card
className={cn(
'flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0 border-dashed',
className
)}
>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
@@ -76,7 +81,12 @@ export function CliproxyStatsCard({
// Error fetching stats
if (error) {
return (
<Card className={cn('flex flex-col h-full border-destructive/50', className)}>
<Card
className={cn(
'flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0 border-destructive/50',
className
)}
>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
@@ -110,7 +120,7 @@ export function CliproxyStatsCard({
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
return (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
<Card className={cn('flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0', className)}>
<CardHeader className="px-3 py-2 border-b bg-muted/5">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
@@ -70,7 +70,7 @@ export function DateRangeFilter({
};
return (
<div className={cn('flex items-center gap-2', className)}>
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{presets.map((preset) => (
<Button
key={preset.label}
@@ -87,7 +87,7 @@ export function DateRangeFilter({
id="date"
variant={'outline'}
className={cn(
'w-auto min-w-[240px] justify-start text-left font-normal',
'w-auto min-w-[200px] sm:min-w-[240px] justify-start text-left font-normal',
!value && 'text-muted-foreground'
)}
>
@@ -34,12 +34,12 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
}, [data]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
return <Skeleton className={cn('h-full min-h-[100px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<div className={cn('h-full min-h-[100px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No model data available</p>
</div>
);
@@ -72,8 +72,8 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
};
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={250}>
<div className={cn('w-full h-full min-h-[100px]', className)}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
@@ -55,7 +55,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
if (isLoading) {
return (
<Card className={cn('flex flex-col h-full', className)}>
<Card className={cn('flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0', className)}>
<CardHeader className="px-3 py-2">
<Skeleton className="h-5 w-32" />
</CardHeader>
@@ -68,7 +68,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
if (!stats) {
return (
<Card className={cn('flex flex-col h-full', className)}>
<Card className={cn('flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0', className)}>
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Terminal className="w-4 h-4" />
@@ -83,14 +83,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
}
return (
<Card className={cn('flex flex-col h-full shadow-sm', className)}>
<Card
className={cn('flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0 shadow-sm', className)}
>
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Terminal className="w-4 h-4" />
Session Stats
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex flex-col gap-4">
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex flex-col gap-3">
{/* Key Metrics Grid */}
<div className="grid grid-cols-2 gap-2">
{/* Total Sessions */}
@@ -119,12 +121,12 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
</div>
{/* Recent Activity List */}
<div className="flex-1 space-y-2">
<div className="flex-1 min-h-0 space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground font-medium mb-1">
<Clock className="w-3 h-3" />
Recent Activity
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 max-h-full overflow-y-auto pr-1">
{stats.recentSessions.map((session) => (
<div
key={session.sessionId}
@@ -23,7 +23,7 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i}>
<CardContent className="p-6">
@@ -95,7 +95,7 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{cards.map((card, index) => {
const Icon = card.icon;
return (
+1 -1
View File
@@ -109,7 +109,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh min-h-0 w-full overflow-hidden',
className
)}
{...props}
@@ -30,12 +30,12 @@ export function AnalyticsHeader({
viewMode,
}: AnalyticsHeaderProps) {
return (
<div className="flex items-center justify-between shrink-0">
<div className="flex flex-col gap-3 shrink-0 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
@@ -45,6 +45,7 @@ export function AnalyticsHeader({
24H
</Button>
<DateRangeFilter
className="flex-wrap"
value={dateRange}
onChange={onDateRangeChange}
presets={[
@@ -44,9 +44,9 @@ export function ChartsGrid({
const { privacyMode } = usePrivacy();
return (
<div className="flex-1 flex flex-col min-h-0 gap-4">
<div className="min-h-0 grid gap-4 lg:grid-rows-[minmax(260px,1.2fr)_minmax(220px,0.9fr)]">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<Card className="flex flex-col h-full min-h-[220px] lg:min-h-[240px] overflow-hidden gap-0 py-0 shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
@@ -63,7 +63,7 @@ export function ChartsGrid({
</Card>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto min-h-[220px] lg:h-full lg:min-h-[220px] lg:grid-rows-[minmax(0,1fr)] lg:[&>*]:min-h-0">
{/* Cost by Model */}
<CostByModelCard
models={models}
@@ -73,7 +73,7 @@ export function ChartsGrid({
/>
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
<Card className="flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0 shadow-sm lg:col-span-2">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<PieChart className="w-4 h-4" />
@@ -26,7 +26,7 @@ export function CostByModelCard({
privacyMode,
}: CostByModelCardProps) {
return (
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<Card className="flex flex-col h-full min-h-0 overflow-hidden gap-0 py-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4" />
+1 -1
View File
@@ -40,7 +40,7 @@ export function AnalyticsPage() {
} = useAnalyticsPage();
return (
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
<div className="grid h-full min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-4 overflow-y-auto px-4 py-4">
{/* Header */}
<AnalyticsHeader
dateRange={dateRange}