Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions clis/codex/ask.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@ export const askCommand = cli({
throw new ArgumentError('--timeout must be a positive integer (seconds)');
}
const selected = await openCodexConversation(page, kwargs);
// Snapshot the current content length before sending
const beforeLen = await page.evaluate(`
// Snapshot the latest assistant identity. Codex virtualizes older turns, so
// message/turn counts can stay constant even after a new response appears.
const beforeState = await page.evaluate(`
(function() {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
return turns.length;
const messages = Array.from(document.querySelectorAll('[data-markdown-text-style="assistant-message"]'));
const lastMessage = messages[messages.length - 1] || null;
const unit = lastMessage?.closest('[data-content-search-unit-key]');
const annotation = lastMessage?.closest('[data-response-annotation-target]');
return {
assistantKey: unit?.getAttribute('data-content-search-unit-key')
|| annotation?.getAttribute('data-response-annotation-target')
|| '',
assistantCount: messages.length,
turnCount: document.querySelectorAll('[data-content-search-turn-key]').length,
};
})()
`);
// Inject and send
Expand All @@ -51,13 +61,34 @@ export const askCommand = cli({
for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);
const result = await page.evaluate(`
(function(prevLen) {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
if (turns.length <= prevLen) return null;
const lastTurn = turns[turns.length - 1];
const text = lastTurn.innerText || lastTurn.textContent;
return text ? text.trim() : null;
})(${beforeLen})
(function(prevState) {
const assistantMessages = Array.from(document.querySelectorAll('[data-markdown-text-style="assistant-message"]'));
const lastMessage = assistantMessages[assistantMessages.length - 1] || null;
if (lastMessage) {
const unit = lastMessage.closest('[data-content-search-unit-key]');
const annotation = lastMessage.closest('[data-response-annotation-target]');
const assistantKey = unit?.getAttribute('data-content-search-unit-key')
|| annotation?.getAttribute('data-response-annotation-target')
|| '';
if ((assistantKey && assistantKey !== prevState.assistantKey)
|| (!assistantKey && assistantMessages.length > prevState.assistantCount)) {
const text = lastMessage.innerText || lastMessage.textContent;
return text ? text.trim() : null;
}
}

// Older Codex builds did not expose assistant-message markers.
if (prevState.assistantCount === 0 && assistantMessages.length === 0) {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
if (turns.length > prevState.turnCount) {
const lastTurn = turns[turns.length - 1];
const text = lastTurn.innerText || lastTurn.textContent;
return text ? text.trim() : null;
}
}

return null;
})(${JSON.stringify(beforeState)})
`);
if (result) {
response = result;
Expand Down
26 changes: 26 additions & 0 deletions clis/codex/ask.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';

import { askCommand } from './ask.js';

describe('codex ask', () => {
it('detects a new assistant identity even when virtualized counts are unchanged', async () => {
const evaluate = vi.fn()
.mockResolvedValueOnce({ assistantKey: 'old-key', assistantCount: 1, turnCount: 1 })
.mockResolvedValueOnce(true)
.mockResolvedValueOnce('OPENCLI_OK');
const page = {
evaluate,
wait: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
};

const result = await askCommand.func(page, { text: 'reply OPENCLI_OK', timeout: 3 });

expect(result).toEqual([
{ Role: 'User', Project: '', Conversation: '', Text: 'reply OPENCLI_OK' },
{ Role: 'Assistant', Project: '', Conversation: '', Text: 'OPENCLI_OK' },
]);
expect(evaluate.mock.calls[0][0]).toContain('data-content-search-unit-key');
expect(evaluate.mock.calls[2][0]).toContain('assistantKey');
});
});
19 changes: 19 additions & 0 deletions src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ describe('browser helpers', () => {

expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex');
});

it('prefers the Codex main renderer over the avatar overlay', () => {
const target = cdpTest.selectCDPTarget([
{
type: 'page',
title: 'Codex',
url: 'app://-/index.html?initialRoute=%2Favatar-overlay',
webSocketDebuggerUrl: 'ws://127.0.0.1:9238/overlay',
},
{
type: 'page',
title: 'Codex',
url: 'app://-/index.html',
webSocketDebuggerUrl: 'ws://127.0.0.1:9238/main',
},
]);

expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9238/main');
});
});

describe('BrowserBridge state', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,8 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
if (url.startsWith('http://127.0.0.1') || url.startsWith('https://127.0.0.1')) score += 50;
if (url.startsWith('about:blank')) score -= 120;
if (url === '' || url === 'about:blank') score -= 40;
// Auxiliary Codex avatar overlay shares the main app's CDP port/title but has no composer.
if (url.includes('initialroute=%2favatar-overlay')) score -= 200;

if (title && title !== 'devtools') score += 25;

Expand Down