Skip to content

Commit de2ef4c

Browse files
jackwenercodex-mini0
andauthored
fix(qwen): anchor waitForAnswer to stop returning the previous answer (#1982)
* fix(qwen): anchor waitForAnswer on pre-send turn to stop returning the previous answer qwen `waitForAnswer` took no baseline and never skipped stale turns — the `seenAssistantId` variable was assigned but never read (dead code). Since `getMessageBubbles` returns every turn including the already-complete previous answer, a follow-up `qwen ask` into an existing conversation (persistent site session, no --new) saw that prior answer on the first polls. It was already stable, so the stability check returned it as if it were the reply to the new prompt — silently wrong output, no error. Mirror grok's reference fix: capture the last assistant turn's id before sending (`getBaselineLastAssistantId` in ask.js) and `continue` in waitForAnswer while the latest assistant id equals that baseline. Removes the dead `seenAssistantId`. Tests: getBaselineLastAssistantId helper (mirrors grok), plus a direct waitForAnswer test asserting the pre-send turn is skipped (times out rather than returning the stale answer) — reverse-validated. * fix(qwen): bind answer wait to sent prompt turn * fix(audit): bump undici in lockfile * fix(qwen): fail closed when answer anchor is not visible --------- Co-authored-by: codex-mini0 <codex-mini0@slock.local>
1 parent 001abdf commit de2ef4c

4 files changed

Lines changed: 199 additions & 9 deletions

File tree

clis/qwen/ask.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,17 @@ cli({
5959
if (useThink) await setFeatureToggle(page, 'think', true);
6060
if (useResearch) await setFeatureToggle(page, 'research', true);
6161

62+
// Anchor on the visible transcript BEFORE sending so waitForAnswer can
63+
// bind the reply to the newly sent prompt instead of an older answer.
64+
const baselineAnchor = await getBaselineChatAnchor(page);
65+
6266
const send = await sendMessage(page, prompt);
6367
if (!send?.ok) {
6468
if (await hasLoginGate(page)) throw authRequired();
6569
throw new CommandExecutionError(send?.reason || 'Failed to send Qianwen prompt');
6670
}
6771

68-
const result = await waitForAnswer(page, prompt, timeout);
72+
const result = await waitForAnswer(page, prompt, timeout, baselineAnchor);
6973
if (result.status === 'auth_required') throw authRequired();
7074
if (result.status === 'timeout') {
7175
throw new TimeoutError('qianwen ask', timeout, 'No Qianwen reply observed before timeout. Retry with --timeout increased.');
@@ -83,3 +87,18 @@ cli({
8387
];
8488
},
8589
});
90+
91+
async function getBaselineChatAnchor(page) {
92+
const bubbles = await getMessageBubbles(page);
93+
const lastBubbleId = bubbles.length ? bubbles[bubbles.length - 1].id : '';
94+
let lastAssistantId = '';
95+
for (let i = bubbles.length - 1; i >= 0; i -= 1) {
96+
if (bubbles[i].role === 'Assistant') {
97+
lastAssistantId = bubbles[i].id;
98+
break;
99+
}
100+
}
101+
return { lastBubbleId, lastAssistantId };
102+
}
103+
104+
export const __test__ = { getBaselineChatAnchor };

clis/qwen/ask.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { __test__ } from './ask.js';
3+
4+
describe('qwen ask helpers', () => {
5+
describe('getBaselineChatAnchor', () => {
6+
// getMessageBubbles drives a page.evaluate that returns the raw bubble
7+
// array; in tests we route page.evaluate straight to an in-memory array.
8+
const fakePage = (bubbles) => ({ evaluate: () => Promise.resolve(bubbles) });
9+
10+
it('returns the last visible bubble and most recent Assistant bubble', async () => {
11+
const bubbles = [
12+
{ id: 'a1', role: 'Assistant', text: 'first answer', html: '' },
13+
{ id: 'u1', role: 'User', text: 'follow-up', html: '' },
14+
{ id: 'a2', role: 'Assistant', text: 'second answer', html: '' },
15+
{ id: 'u2', role: 'User', text: 'newest user turn', html: '' },
16+
];
17+
expect(await __test__.getBaselineChatAnchor(fakePage(bubbles))).toEqual({
18+
lastBubbleId: 'u2',
19+
lastAssistantId: 'a2',
20+
});
21+
});
22+
23+
it('returns empty assistant id when no Assistant bubble exists yet (fresh chat)', async () => {
24+
expect(await __test__.getBaselineChatAnchor(fakePage([]))).toEqual({
25+
lastBubbleId: '',
26+
lastAssistantId: '',
27+
});
28+
expect(await __test__.getBaselineChatAnchor(fakePage([
29+
{ id: 'u1', role: 'User', text: 'hello', html: '' },
30+
]))).toEqual({
31+
lastBubbleId: 'u1',
32+
lastAssistantId: '',
33+
});
34+
});
35+
});
36+
});

clis/qwen/utils.js

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,62 @@ function stripNoise(text) {
284284
.trim();
285285
}
286286

287-
export async function waitForAnswer(page, prompt, timeoutSeconds) {
287+
function normalizePromptText(text) {
288+
return stripNoise(text).replace(/\s+/g, ' ').trim();
289+
}
290+
291+
function normalizeWaitAnchor(anchor) {
292+
if (typeof anchor === 'string') {
293+
return { lastBubbleId: '', lastAssistantId: anchor };
294+
}
295+
return {
296+
lastBubbleId: String(anchor?.lastBubbleId || ''),
297+
lastAssistantId: String(anchor?.lastAssistantId || ''),
298+
};
299+
}
300+
301+
function findAssistantAfterPrompt(bubbles, prompt, anchor) {
302+
const normalizedPrompt = normalizePromptText(prompt);
303+
let startIndex = -1;
304+
const hasAnchor = Boolean(anchor.lastBubbleId || anchor.lastAssistantId);
305+
let foundAnchor = false;
306+
if (anchor.lastBubbleId) {
307+
const lastBubbleIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastBubbleId);
308+
if (lastBubbleIndex >= 0) {
309+
startIndex = lastBubbleIndex;
310+
foundAnchor = true;
311+
}
312+
}
313+
if (anchor.lastAssistantId) {
314+
const lastAssistantIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastAssistantId);
315+
if (lastAssistantIndex >= 0) {
316+
startIndex = Math.max(startIndex, lastAssistantIndex);
317+
foundAnchor = true;
318+
}
319+
}
320+
if (hasAnchor && !foundAnchor) return null;
321+
322+
let promptIndex = -1;
323+
for (let i = startIndex + 1; i < bubbles.length; i += 1) {
324+
if (bubbles[i].role === 'User' && normalizePromptText(bubbles[i].text) === normalizedPrompt) {
325+
promptIndex = i;
326+
}
327+
}
328+
if (promptIndex < 0) return null;
329+
330+
for (let i = promptIndex + 1; i < bubbles.length; i += 1) {
331+
if (bubbles[i].role === 'Assistant') return bubbles[i];
332+
}
333+
return null;
334+
}
335+
336+
export async function waitForAnswer(page, prompt, timeoutSeconds, baselineAnchor) {
337+
const anchor = normalizeWaitAnchor(baselineAnchor);
288338
const startTime = Date.now();
289339
let previousText = '';
290340
let stableCount = 0;
291341
let lastCandidate = '';
292-
let seenAssistantId = '';
342+
let lastAssistantCandidate = null;
293343

294344
while (Date.now() - startTime < timeoutSeconds * 1000) {
295345
await page.wait(POLL_INTERVAL_SECONDS);
@@ -299,14 +349,18 @@ export async function waitForAnswer(page, prompt, timeoutSeconds) {
299349
}
300350

301351
const bubbles = await getMessageBubbles(page);
302-
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
352+
const lastAssistant = findAssistantAfterPrompt(bubbles, prompt, anchor);
303353
if (!lastAssistant) continue;
304354

355+
// Guard against stale assistant turns that existed before our send even
356+
// if the DOM briefly reorders or rehydrates while the new turn loads.
357+
if (anchor.lastAssistantId && lastAssistant.id === anchor.lastAssistantId) continue;
358+
305359
const text = stripNoise(lastAssistant.text);
306360
if (!text || text === prompt) continue;
307361

308-
if (!seenAssistantId) seenAssistantId = lastAssistant.id;
309362
lastCandidate = text;
363+
lastAssistantCandidate = lastAssistant;
310364

311365
const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS;
312366
if (text === previousText) {
@@ -321,9 +375,7 @@ export async function waitForAnswer(page, prompt, timeoutSeconds) {
321375
}
322376

323377
if (lastCandidate) {
324-
const bubbles = await getMessageBubbles(page);
325-
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
326-
return { status: 'partial', assistant: lastAssistant };
378+
return { status: 'partial', assistant: lastAssistantCandidate };
327379
}
328380
return { status: 'timeout' };
329381
}

clis/qwen/utils.test.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,89 @@
11
import { describe, expect, it } from 'vitest';
22
import { ArgumentError } from '@jackwener/opencli/errors';
3-
import { parseQianwenSessionId } from './utils.js';
3+
import { parseQianwenSessionId, waitForAnswer } from './utils.js';
4+
5+
describe('qwen waitForAnswer baseline anchoring', () => {
6+
// page.evaluate serves both getMessageBubbles (data-chat-question-wrap) and
7+
// hasLoginGate (alert-biz-modal); route by inspecting the injected JS.
8+
const fakePage = (bubbles) => ({
9+
wait: () => new Promise((r) => setTimeout(r, 5)),
10+
evaluate: (js) => Promise.resolve(
11+
String(js).includes('alert-biz-modal') ? false : bubbles,
12+
),
13+
});
14+
15+
it('does not return the pre-send assistant turn (it is skipped via baseline id)', async () => {
16+
// Only the previous, already-complete answer is present. With the
17+
// baseline anchored to its id, waitForAnswer must skip it and time out
18+
// rather than return the stale answer as the new reply.
19+
const bubbles = [{ id: 'a1', role: 'Assistant', text: 'old answer', html: '' }];
20+
const result = await waitForAnswer(fakePage(bubbles), 'new question', 0.05, 'a1');
21+
expect(result.status).toBe('timeout');
22+
expect(result.assistant).toBeUndefined();
23+
});
24+
25+
it('returns only an assistant turn after the newly sent prompt', async () => {
26+
const bubbles = [
27+
{ id: 'u1', role: 'User', text: 'old question', html: '' },
28+
{ id: 'a1', role: 'Assistant', text: 'old answer', html: '' },
29+
{ id: 'u2', role: 'User', text: 'new question', html: '' },
30+
{ id: 'a2', role: 'Assistant', text: 'new answer', html: '<p>new answer</p>' },
31+
];
32+
const result = await waitForAnswer(
33+
fakePage(bubbles),
34+
'new question',
35+
0.2,
36+
{ lastBubbleId: 'a1', lastAssistantId: 'a1' },
37+
);
38+
expect(result.status).toBe('partial');
39+
expect(result.assistant).toEqual(bubbles[3]);
40+
});
41+
42+
it('does not match a repeated prompt that existed before the pre-send anchor', async () => {
43+
const bubbles = [
44+
{ id: 'u1', role: 'User', text: 'same prompt', html: '' },
45+
{ id: 'a1', role: 'Assistant', text: 'old answer', html: '' },
46+
];
47+
const result = await waitForAnswer(
48+
fakePage(bubbles),
49+
'same prompt',
50+
0.05,
51+
{ lastBubbleId: 'a1', lastAssistantId: 'a1' },
52+
);
53+
expect(result.status).toBe('timeout');
54+
expect(result.assistant).toBeUndefined();
55+
});
56+
57+
it('does not scan the visible transcript when a non-empty baseline anchor is missing', async () => {
58+
const bubbles = [
59+
{ id: 'u1-new-visible-id', role: 'User', text: 'same prompt', html: '' },
60+
{ id: 'a1-new-visible-id', role: 'Assistant', text: 'old answer', html: '' },
61+
];
62+
const result = await waitForAnswer(
63+
fakePage(bubbles),
64+
'same prompt',
65+
0.05,
66+
{ lastBubbleId: 'a-anchor-missing', lastAssistantId: 'a-anchor-missing' },
67+
);
68+
expect(result.status).toBe('timeout');
69+
expect(result.assistant).toBeUndefined();
70+
});
71+
72+
it('supports a fresh chat with no baseline anchor', async () => {
73+
const bubbles = [
74+
{ id: 'u1', role: 'User', text: 'fresh question', html: '' },
75+
{ id: 'a1', role: 'Assistant', text: 'fresh answer', html: '' },
76+
];
77+
const result = await waitForAnswer(
78+
fakePage(bubbles),
79+
'fresh question',
80+
0.2,
81+
{ lastBubbleId: '', lastAssistantId: '' },
82+
);
83+
expect(result.status).toBe('partial');
84+
expect(result.assistant).toEqual(bubbles[1]);
85+
});
86+
});
487

588
describe('qwen parseQianwenSessionId', () => {
689
const id = 'abcd1234ef567890abcd1234ef567890';

0 commit comments

Comments
 (0)