fix(session-list): keep composite cursors usable by ACP and VS Code clients

listSessions mints "<mtime>|<name>" cursors whenever a page ends inside a
group of files sharing an mtime, but two consumers still parsed every cursor
as a number: ACP session/list rejected page 2 with "not a valid numeric
cursor", and the VS Code companion turned the composite cursor into NaN so
hasMore read false and only page 1 was ever listed. A malformed "|name"
cursor also slipped through the persisted-cursor classifier and answered 200
with an empty page instead of invalid_cursor.

- acpAgent: accept and forward "<mtime>|<name>" cursors to listSessions
- session-list: require a non-empty mtime prefix before classifying a
  cursor as persisted
- vscode companion: pass the daemon cursor back verbatim, widen the cursor
  type and treat only a non-empty cursor as "append to the current page"

Tests: ACP composite passthrough + empty-prefix rejection, VS Code
getSessionListPaged composite nextCursor round trip.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvxke6bm6i
This commit is contained in:
jinjing.zzjandQwen-Coder committed 2026-11-01 16:39:40 +08:00
1 parent d6b98baa90
commit 021698420c
6 files changed
+103 -21

No files matched your search

@@ -30066,9 +30066,7 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
]) {
await expect(
agent.unstable_listSessions({ cwd: '/tmp/project', cursor }),
).rejects.toThrow(
`Invalid cursor: "${cursor}" is not a valid numeric cursor`,
);
).rejects.toThrow(`Invalid cursor: "${cursor}" is not a valid cursor`);
}
expect(SessionService).not.toHaveBeenCalled();
} finally {
@@ -30111,6 +30109,54 @@ describe('QwenAgent unstable_listSessions cursor parsing', () => {
}
});
it('passes a composite cursor through so page 2 stays reachable', async () => {
const listSessions = vi.fn().mockResolvedValue({
items: [],
nextCursor: undefined,
});
vi.mocked(SessionService).mockImplementation(
() =>
({
listSessions,
}) as unknown as InstanceType<typeof SessionService>,
);
const { agent, agentPromise } = await bootAgent();
try {
await agent.unstable_listSessions({
cwd: '/tmp/project',
cursor: '1700000000000|session-a.jsonl',
});
expect(listSessions).toHaveBeenCalledWith({
cursor: '1700000000000|session-a.jsonl',
size: undefined,
excludeSourceTypes: ['agent-host', 'agent'],
});
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
it('rejects a composite cursor with an empty mtime prefix', async () => {
const { agent, agentPromise } = await bootAgent();
try {
await expect(
agent.unstable_listSessions({
cwd: '/tmp/project',
cursor: '|session-a.jsonl',
}),
).rejects.toThrow(
'Invalid cursor: "|session-a.jsonl" is not a valid cursor',
);
expect(SessionService).not.toHaveBeenCalled();
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
it('ignores invalid _meta.size values', async () => {
const listSessions = vi.fn().mockResolvedValue({
items: [],
+10 -2
View File
@@ -3719,9 +3719,17 @@ function normalizeAcpSessionListSize(value: unknown): number | undefined {
function parseAcpSessionListCursor(
value: string | null | undefined,
): number | undefined {
): number | string | undefined {
if (value == null || value === '') return undefined;
const trimmed = value.trim();
// Composite cursors ("<mtime>|<name>") are minted by listSessions when a page
// ends inside a group of files sharing an mtime; they must survive the round
// trip or the client cannot fetch page 2.
const separator = trimmed.indexOf('|');
if (separator > 0 && separator < trimmed.length - 1) {
const mtime = Number(trimmed.slice(0, separator));
if (Number.isFinite(mtime) && mtime >= 0) return trimmed;
}
const parsedCursor = Number(trimmed);
if (
trimmed === '' ||
@@ -3731,7 +3739,7 @@ function parseAcpSessionListCursor(
) {
throw RequestError.invalidParams(
undefined,
`Invalid cursor: "${value}" is not a valid numeric cursor`,
`Invalid cursor: "${value}" is not a valid cursor`,
);
}
return parsedCursor;
@@ -164,9 +164,11 @@ export function isPersistedSessionCursor(cursor: string): boolean {
if (cursor === '') return false;
const trimmed = cursor.trim();
if (trimmed === '') return false;
// Composite cursors are opaque to this layer beyond their mtime prefix.
// Composite cursors are opaque to this layer beyond their mtime prefix. An
// empty prefix ("|name") is malformed: Number('') is 0, so without this
// guard it would pass as a real position and answer 200 with an empty page.
const parts = trimmed.split('|');
if (parts.length === 2 && parts[1].length > 0) {
if (parts.length === 2 && parts[0].trim() !== '' && parts[1].length > 0) {
const mtime = Number(parts[0]);
if (Number.isFinite(mtime) && mtime >= 0) return true;
}
@@ -454,3 +454,30 @@ describe('QwenAgentManager session-update transcript forwarding', () => {
expect(onTranscriptUpdate).toHaveBeenCalledWith(notification);
});
});
describe('QwenAgentManager.getSessionListPaged', () => {
it('keeps a composite nextCursor so page 2 stays reachable', async () => {
const manager = new QwenAgentManager();
const listSessions = vi.fn().mockResolvedValue({
items: [{ sessionId: 'session-1' }],
nextCursor: '1700000000000|session-1.jsonl',
});
(
manager as unknown as {
connection: { listSessions: typeof listSessions };
}
).connection = { listSessions };
const page = await manager.getSessionListPaged({ size: 1 });
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toBe('1700000000000|session-1.jsonl');
await manager.getSessionListPaged({ size: 1, cursor: page.nextCursor });
expect(listSessions).toHaveBeenLastCalledWith({
size: 1,
cursor: '1700000000000|session-1.jsonl',
});
});
});
@@ -559,11 +559,11 @@ export class QwenAgentManager {
* Falls back to file system scan with equivalent pagination semantics.
*/
async getSessionListPaged(params?: {
cursor?: number;
cursor?: number | string;
size?: number;
}): Promise<{
sessions: Array<Record<string, unknown>>;
nextCursor?: number;
nextCursor?: number | string;
hasMore: boolean;
}> {
const size = params?.size ?? 20;
@@ -590,22 +590,21 @@ export class QwenAgentManager {
cwd: item.cwd,
}));
// SDK returns nextCursor as string; convert to numeric cursor for paging
let nextCursorNum: number | undefined;
// Keep the daemon's cursor verbatim: the composite "<mtime>|<name>" form
// would become NaN under Number() and read as "no more pages".
let nextCursorValue: number | string | undefined;
if (typeof res === 'object' && res !== null && 'nextCursor' in res) {
const raw = (res as { nextCursor?: unknown }).nextCursor;
if (typeof raw === 'number') {
nextCursorNum = raw;
} else if (typeof raw === 'string') {
nextCursorValue = raw;
} else if (typeof raw === 'string' && raw !== '') {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) {
nextCursorNum = parsed;
}
nextCursorValue = Number.isNaN(parsed) ? raw : parsed;
}
}
const hasMore = nextCursorNum !== undefined;
const hasMore = nextCursorValue !== undefined;
return { sessions: mapped, nextCursor: nextCursorNum, hasMore };
return { sessions: mapped, nextCursor: nextCursorValue, hasMore };
} catch (error) {
logger.warn('[QwenAgentManager] Paged ACP session list failed:', error);
// fall through to file system
@@ -190,7 +190,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
case 'getQwenSessions':
await this.handleGetQwenSessions(
(data?.cursor as number | undefined) ?? undefined,
(data?.cursor as number | string | undefined) ?? undefined,
(data?.size as number | undefined) ?? undefined,
);
break;
@@ -1446,7 +1446,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
* Handle get Qwen sessions request
*/
private async handleGetQwenSessions(
cursor?: number,
cursor?: number | string,
size?: number,
): Promise<void> {
try {
@@ -1455,7 +1455,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
cursor,
size,
});
const append = typeof cursor === 'number';
const append = cursor !== undefined && cursor !== '';
this.sendToWebView({
type: 'qwenSessionList',
data: {