fix(chat): preserve selected models and wait for Kimi replies (#2266)

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
bulexu
2026-08-09 22:52:27 +08:00
committed by GitHub
parent 27ba33fcb8
commit a93f6e71bb
6 changed files with 154 additions and 25 deletions
+1 -2
View File
@@ -7312,9 +7312,8 @@
{
"name": "model",
"type": "str",
"default": "sonnet",
"required": false,
"help": "Model to use: sonnet, opus, or haiku",
"help": "Switch to sonnet, opus, or haiku before sending. Omit to keep the current selection.",
"choices": [
"sonnet",
"opus",
+10 -9
View File
@@ -21,7 +21,7 @@ export const askCommand = cli({
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
{ name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' },
{ name: 'model', choices: ['sonnet', 'opus', 'haiku'], help: 'Switch to sonnet, opus, or haiku before sending. Omit to keep the current selection.' },
{ name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' },
{ name: 'file', help: 'Attach a file (image, PDF, text) with the prompt' },
],
@@ -68,20 +68,21 @@ export const askCommand = cli({
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
// Model selector is only available on the new-chat page, not inside
// an existing conversation. Skip it when we resumed a prior thread.
// an existing conversation. Also avoid changing the user's current
// UI selection unless --model was explicitly passed.
const currentUrl = await page.evaluate('window.location.href') || '';
const inConversation = currentUrl.includes('/chat/');
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
const wantModel = kwargs.model || 'sonnet';
if (inConversation && modelExplicit) {
throw new ArgumentError(
`Cannot switch to ${wantModel} model inside an existing conversation.`,
'Re-run with --new to start a fresh chat before selecting a model.',
);
}
if (modelExplicit) {
if (inConversation) {
throw new ArgumentError(
`Cannot switch to ${wantModel} model inside an existing conversation.`,
'Re-run with --new to start a fresh chat before selecting a model.',
);
}
if (!inConversation) {
const modelResult = await withRetry(() => selectModel(page, wantModel));
if (!modelResult?.ok) {
if (modelResult?.upgrade) {
+15 -1
View File
@@ -148,12 +148,27 @@ describe('claude ask --model handling', () => {
new: false,
model: 'opus',
think: false,
__opencliOptionSources: { model: 'cli' },
})).rejects.toMatchObject(new ArgumentError(
'opus model requires a paid Claude plan.',
'Pick --model sonnet or --model haiku, or upgrade your account.',
));
});
it('does not switch model on /new when --model is not explicit', async () => {
page.evaluate.mockResolvedValue('https://claude.ai/new');
const rows = await askCommand.func(page, {
prompt: 'hi',
timeout: 120,
new: false,
think: false,
});
expect(rows).toEqual([{ response: 'reply' }]);
expect(mockSelectModel).not.toHaveBeenCalled();
});
it('skips model selection inside an existing conversation', async () => {
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
@@ -161,7 +176,6 @@ describe('claude ask --model handling', () => {
prompt: 'continue',
timeout: 120,
new: false,
model: 'sonnet',
think: false,
});
+23 -9
View File
@@ -78,22 +78,36 @@ export function clickBySvgNameScript(svgName, opts = {}) {
const svgs = Array.from(document.querySelectorAll('svg[name="' + ${JSON.stringify(svgName)} + '"], svg[role="img"][name="' + ${JSON.stringify(svgName)} + '"]')).filter(isVisible);
if (!svgs.length) return { ok: false, reason: 'No visible svg[name="' + ${JSON.stringify(svgName)} + '"].' };
const svg = ${last ? 'svgs[svgs.length - 1]' : 'svgs[0]'};
// Walk up to the nearest clickable ancestor.
let target = svg;
for (let i = 0; i < 6; i++) {
const parent = target.parentElement;
if (!parent) break;
target = parent;
if (target.tagName === 'BUTTON' || target.getAttribute('role') === 'button' || target.onclick || target.tagName === 'A') break;
// React handlers are delegated, so inline onclick is often empty even on
// clickable Kimi controls. Keep the nearest parent as the fallback, and
// only replace it when a scanned ancestor is explicitly recognizable.
const isClickTarget = (el) => {
if (!el) return false;
const tag = el.tagName;
const role = el.getAttribute('role');
const cls = String(el.className || '');
if (tag === 'BUTTON' || tag === 'A' || role === 'button') return true;
if (/send-button-container|operation|action|button|btn/i.test(cls)) return true;
return getComputedStyle(el).cursor === 'pointer';
};
const fallback = svg.parentElement || svg;
let target = fallback;
let candidate = fallback;
for (let i = 0; candidate && i < 6; i++) {
if (isClickTarget(candidate)) {
target = candidate;
break;
}
candidate = candidate.parentElement;
}
const r = target.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
const opts = { bubbles: true, cancelable: true, view: window, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
target.dispatchEvent(new PointerEvent('pointerdown', opts));
target.dispatchEvent(new MouseEvent('mousedown', opts));
target.dispatchEvent(new PointerEvent('pointerup', opts));
target.dispatchEvent(new MouseEvent('mouseup', opts));
target.click();
return { ok: true };
return { ok: true, targetTag: target.tagName, targetClass: String(target.className || '') };
})()`;
}
+16 -3
View File
@@ -227,14 +227,16 @@ async function readKimiTurns(page) {
const turns = [];
const seen = new Set();
for (const row of rows) {
const tx = (row.innerText || row.textContent || '').trim().replace(/\\s+/g, ' ');
const clone = row.cloneNode(true);
clone.querySelectorAll('.toolcall-container').forEach((el) => el.remove());
const tx = (clone.innerText || clone.textContent || '').trim().replace(/\\s+/g, ' ');
if (!tx || tx.length < 2) continue;
if (seen.has(tx)) continue;
const cls = (row.className || '').toString().toLowerCase();
let role = 'Turn';
if (/chat-content-item-user|segment-user|user|sent-by-user|me-/i.test(cls)) role = 'User';
else if (/chat-content-item-assistant|segment-assistant|assistant|ai-|kimi-|response/i.test(cls)) role = 'Assistant';
else if (row.querySelector('svg[name="Copy"], svg[name="Refresh"], svg[name="Like"]')) role = 'Assistant';
else if (row.querySelector('svg[name="Refresh"], svg[name="Like"]')) role = 'Assistant';
else role = 'User';
seen.add(tx);
turns.push({ role, text: tx });
@@ -243,6 +245,16 @@ async function readKimiTurns(page) {
})()`);
}
async function isKimiGenerating(page) {
return page.evaluate(`(() => {
${IS_VISIBLE_JS}
const activeControl = document.querySelector('.send-button-container.disabled.stop, svg[name="Stop"]');
if (activeControl && isVisible(activeControl)) return true;
const editor = document.querySelector('[contenteditable="true"][role="textbox"]');
return !!editor && editor.getAttribute('aria-disabled') === 'true';
})()`).catch(() => false);
}
async function sendKimiMessage(page, text) {
const prompt = String(text || '').trim();
if (!prompt) throw new ArgumentError('text', 'is required');
@@ -454,7 +466,8 @@ cli({
latestText = next;
stable = 0;
}
if (latestText && stable >= 2) break;
const generating = await isKimiGenerating(page);
if (latestText && stable >= 2 && !generating) break;
}
const elapsed = Math.round((Date.now() - startedAt) / 1000);
if (!latestText) {
+89 -1
View File
@@ -1,7 +1,8 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
import { isKimiUrl, parseChatId } from './_utils.js';
import { JSDOM } from 'jsdom';
import { clickBySvgNameScript, isKimiUrl, parseChatId } from './_utils.js';
import './chat.js';
import './ui.js';
import './storage.js';
@@ -130,6 +131,61 @@ describe('kimi target boundary', () => {
});
});
describe('kimi svg click helper', () => {
function runClickScript(html) {
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
const { window } = dom;
if (!window.PointerEvent) window.PointerEvent = window.MouseEvent;
Object.defineProperty(window.Element.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => ({ x: 0, y: 0, width: 20, height: 20, top: 0, left: 0, right: 20, bottom: 20 }),
});
const clicked = [];
window.document.querySelectorAll('[data-click-id]').forEach((el) => {
el.addEventListener('click', (event) => {
clicked.push({
id: el.getAttribute('data-click-id'),
targetId: event.target?.getAttribute?.('data-click-id') || '',
});
});
});
const result = window.eval(clickBySvgNameScript('Send'));
return { clicked, result };
}
it('falls back to the direct React parent when ancestors are generic wrappers', () => {
const { clicked, result } = runClickScript(`
<div data-click-id="wrapper">
<div data-click-id="owner">
<svg name="Send"></svg>
</div>
</div>
`);
expect(result).toMatchObject({ ok: true, targetTag: 'DIV' });
expect(clicked).toEqual([
{ id: 'owner', targetId: 'owner' },
{ id: 'wrapper', targetId: 'owner' },
]);
});
it('uses a recognizable clickable grandparent instead of the generic direct parent', () => {
const { clicked, result } = runClickScript(`
<div class="send-button-container" data-click-id="button">
<div data-click-id="inner">
<svg name="Send"></svg>
</div>
</div>
`);
expect(result).toMatchObject({ ok: true, targetClass: 'send-button-container' });
expect(clicked).toEqual([
{ id: 'button', targetId: 'button' },
]);
});
});
describe('kimi write postconditions', () => {
let sendCommand;
let askCommand;
@@ -189,6 +245,38 @@ describe('kimi write postconditions', () => {
}
});
it('ask waits for generation to stop before returning stable assistant text', async () => {
const page = makePage([
'https://www.kimi.com/',
[],
'https://www.kimi.com/',
0,
{ ok: true },
{ ok: true },
true,
[{ role: 'Assistant', text: '思考中' }],
true,
[{ role: 'Assistant', text: '思考中' }],
true,
[{ role: 'Assistant', text: '思考中' }],
true,
[{ role: 'Assistant', text: '思考中' }],
false,
]);
let now = 1_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
now += 200;
return now;
});
try {
const rows = await askCommand.func(page, { text: 'ping', timeout: 10 });
expect(rows[0].Status).toBe('reply-received');
expect(rows[0].ReplyPreview).toBe('思考中');
} finally {
nowSpy.mockRestore();
}
});
it('model rejects ambiguous partial names before clicking an option', async () => {
const page = makePage([
'https://www.kimi.com/',