fix(chat): preserve selected models and wait for Kimi replies (#2266)
Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
+1
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/',
|
||||
|
||||
Reference in New Issue
Block a user