mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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: [],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user