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
15 changes: 11 additions & 4 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5387,7 +5387,14 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
const { agent, agentPromise } = await bootAgent();

try {
for (const cursor of ['abc', 'Infinity', '-Infinity']) {
for (const cursor of [
'abc',
'Infinity',
'-Infinity',
'-1',
'9007199254740992',
' ',
]) {
await expect(
agent.unstable_listSessions({ cwd: '/tmp/project', cursor }),
).rejects.toThrow(
Expand Down Expand Up @@ -5517,7 +5524,7 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
}
});

it('passes a finite cursor through to SessionService', async () => {
it('passes a finite non-negative cursor through to SessionService', async () => {
const listSessions = vi.fn().mockResolvedValue({
items: [
{
Expand All @@ -5542,7 +5549,7 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
await expect(
agent.unstable_listSessions({
cwd: '/tmp/project',
cursor: '1797860000000',
cursor: '1797860000000.5',
_meta: { size: 2 },
}),
).resolves.toEqual({
Expand All @@ -5563,7 +5570,7 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
});
expect(SessionService).toHaveBeenCalledWith('/tmp/project');
expect(listSessions).toHaveBeenCalledWith({
cursor: 1_797_860_000_000,
cursor: 1_797_860_000_000.5,
size: 2,
});
} finally {
Expand Down
32 changes: 21 additions & 11 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,26 @@ function normalizeAcpSessionListSize(value: unknown): number | undefined {
return Math.min(Math.max(value, 1), MAX_ACP_SESSION_PAGE_SIZE);
}

function parseAcpSessionListCursor(
value: string | null | undefined,
): number | undefined {
if (value == null || value === '') return undefined;
const trimmed = value.trim();
const parsedCursor = Number(trimmed);
if (
trimmed === '' ||
!Number.isFinite(parsedCursor) ||
parsedCursor < 0 ||
parsedCursor > Number.MAX_SAFE_INTEGER
) {
throw RequestError.invalidParams(
undefined,
`Invalid cursor: "${value}" is not a valid numeric cursor`,
);
}
return parsedCursor;
}

class QwenAgent implements Agent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: ClientCapabilities | undefined;
Expand Down Expand Up @@ -2913,17 +2933,7 @@ class QwenAgent implements Agent {
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
const cwd = params.cwd || process.cwd();
let numericCursor: number | undefined;
if (params.cursor != null && params.cursor !== '') {
const parsedCursor = Number(params.cursor);
if (!Number.isFinite(parsedCursor)) {
throw RequestError.invalidParams(
undefined,
`Invalid cursor: "${params.cursor}" is not a valid numeric cursor`,
);
}
numericCursor = parsedCursor;
}
const numericCursor = parseAcpSessionListCursor(params.cursor);

// The ACP spec's ListSessionsRequest doesn't include a page-size field,
// so the SDK's zod validator strips any top-level `size` the client sends
Expand Down
61 changes: 57 additions & 4 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5449,18 +5449,71 @@ describe('createServeApp', () => {
expect(cursoredIds).not.toContain('live-only');
});

it('400 invalid_cursor when cursor is not a valid number', async () => {
it.each(['abc', '-1', 'Infinity', '9007199254740992', ' '])(
'400 invalid_cursor when cursor is not valid: %s',
async (cursor) => {
const bridge = fakeBridge();
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge, boundWorkspace: WS_BOUND },
);
const res = await request(app)
.get(
`/workspace/${encodeURIComponent(WS_BOUND)}/sessions?cursor=${encodeURIComponent(cursor)}`,
)
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_cursor');
},
);

it('accepts fractional mtime cursor values', async () => {
const id = '550e8400-e29b-41d4-a716-446655440000';
await writeStoredSession({
sessionId: id,
cwd: WS_BOUND,
timestamp: '1970-01-01T00:16:39.000Z',
prompt: 'stored prompt',
mtime: new Date('1970-01-01T00:16:39.000Z'),
});
const bridge = fakeBridge();
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge, boundWorkspace: WS_BOUND },
);
const res = await request(app)
.get(`/workspace/${encodeURIComponent(WS_BOUND)}/sessions?cursor=abc`)
.get(
`/workspace/${encodeURIComponent(WS_BOUND)}/sessions?cursor=1000123.456`,
)
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_cursor');
expect(res.status).toBe(200);
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].sessionId).toBe(id);
});

it('passes fractional cursor values to SessionService without truncating', async () => {
const listSessionsSpy = vi
.spyOn(SessionService.prototype, 'listSessions')
.mockResolvedValue({
items: [],
nextCursor: undefined,
hasMore: false,
});

try {
await listWorkspaceSessionsForResponse(fakeBridge(), WS_BOUND, {
cursor: '1000123.456',
});

expect(listSessionsSpy).toHaveBeenCalledWith({
cursor: 1000123.456,
size: 20,
});
} finally {
listSessionsSpy.mockRestore();
}
});

it('excludes live sessions from subsequent pages to prevent cross-page duplicates', async () => {
Expand Down
23 changes: 17 additions & 6 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,21 @@ export class InvalidCursorError extends Error {
}
}

function parseSessionCursor(cursor: string): number | undefined {
if (cursor === '') return undefined;
const trimmed = cursor.trim();
const parsed = Number(trimmed);
if (
trimmed === '' ||
!Number.isFinite(parsed) ||
parsed < 0 ||
parsed > Number.MAX_SAFE_INTEGER
) {
throw new InvalidCursorError(cursor);
}
return parsed;
}

export async function listWorkspaceSessionsForResponse(
bridge: AcpSessionBridge,
workspaceCwd: string,
Expand All @@ -250,12 +265,8 @@ export async function listWorkspaceSessionsForResponse(
const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE);

let numericCursor: number | undefined;
if (options?.cursor) {
const parsed = Number(options.cursor);
if (!Number.isFinite(parsed)) {
throw new InvalidCursorError(options.cursor);
}
numericCursor = parsed;
if (options?.cursor != null) {
numericCursor = parseSessionCursor(options.cursor);
}
const isFirstPage = numericCursor === undefined;

Expand Down
Loading