diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 3be472b2f..80323aef3 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -124,14 +124,13 @@ export async function resolveChatGptToolConfirmation( timeoutMs = CHATGPT_TOOL_CONFIRMATION_TIMEOUT_MS, onVisible?: () => Promise, ): Promise { - const dialog = page.locator('[role="dialog"]') - .filter({ hasText: `Allow ChatGPT to use ${appName}?` }) - .last(); - if (!await dialog.isVisible().catch(() => false)) return false; + const allowOnce = page.getByRole("button", { name: /^(?:Allow once|허용하기)$/, exact: true }).last(); + const deny = page.getByRole("button", { name: /^(?:Deny|거절하기)$/, exact: true }).last(); + if (!await allowOnce.isVisible().catch(() => false) && !await deny.isVisible().catch(() => false)) return false; + if (!await page.getByText(appName, { exact: true }).last().isVisible().catch(() => false)) return false; await onVisible?.(); if (autoApprove) { - const allowOnce = dialog.getByRole("button", { name: "Allow once", exact: true }).last(); await allowOnce.waitFor({ state: "visible", timeout: 10_000 }); await allowOnce.press("Enter"); return true; @@ -140,15 +139,14 @@ export async function resolveChatGptToolConfirmation( const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (signal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); - if (!await dialog.isVisible().catch(() => false)) return true; + if (!await allowOnce.isVisible().catch(() => false) && !await deny.isVisible().catch(() => false)) return true; await new Promise(resolveSleep => setTimeout(resolveSleep, Math.min(100, Math.max(1, deadline - Date.now())))); } - if (!await dialog.isVisible().catch(() => false)) return true; - const deny = dialog.getByRole("button", { name: "Deny", exact: true }).last(); + if (!await allowOnce.isVisible().catch(() => false) && !await deny.isVisible().catch(() => false)) return true; await deny.waitFor({ state: "visible", timeout: 5_000 }); await deny.press("Enter"); - await dialog.waitFor({ state: "hidden", timeout: 10_000 }); + await deny.waitFor({ state: "hidden", timeout: 10_000 }); return true; } diff --git a/src/adapters/chatgpt-web/markdown.ts b/src/adapters/chatgpt-web/markdown.ts index d9dea92e9..598a788f0 100644 --- a/src/adapters/chatgpt-web/markdown.ts +++ b/src/adapters/chatgpt-web/markdown.ts @@ -57,6 +57,7 @@ interface ChatGptMarkdownCandidate extends ChatGptMarkdownSegment { interface CommittedChatGptMarkdownSegment { key: string; text: string; + retired: boolean; } /** @@ -69,7 +70,7 @@ interface CommittedChatGptMarkdownSegment { * visible text is an explicit protocol error because Responses deltas cannot be retracted. */ export class ChatGptMarkdownBuffer { - private readonly candidates = new Map(); + private readonly candidates = new Map(); private readonly committed: CommittedChatGptMarkdownSegment[] = []; private latest: ChatGptMarkdownSegment[] = []; private markdown = ""; @@ -85,18 +86,19 @@ export class ChatGptMarkdownBuffer { } observe(segments: ChatGptMarkdownSegment[], now = Date.now()): string { - this.assertCommittedPrefix(segments); + this.reconcileCommitted(segments); this.latest = segments.map(segment => ({ ...segment })); - for (let index = this.committed.length; index < segments.length; index += 1) { - const segment = segments[index]!; - const previous = this.candidates.get(index); + const pending = segments.filter(segment => !this.isCommitted(segment)); + for (const segment of pending) { + const identity = this.identity(segment); + const previous = this.candidates.get(identity); const unchanged = previous && previous.key === segment.key && previous.html === segment.html && previous.text === segment.text && previous.group === segment.group; - this.candidates.set(index, { + this.candidates.set(identity, { ...segment, changedAt: unchanged ? previous.changedAt : now, ...(segment.streamable ? { @@ -106,48 +108,61 @@ export class ChatGptMarkdownBuffer { } : {}), }); } - for (const index of this.candidates.keys()) { - if (index >= segments.length) this.candidates.delete(index); + const pendingIdentities = new Set(pending.map(segment => this.identity(segment))); + for (const identity of this.candidates.keys()) { + if (!pendingIdentities.has(identity)) this.candidates.delete(identity); } let delta = ""; - while (this.committed.length < segments.length) { - const index = this.committed.length; - const candidate = this.candidates.get(index); + for (const segment of segments) { + if (this.isCommitted(segment)) continue; + const identity = this.identity(segment); + const candidate = this.candidates.get(identity); if (!candidate?.streamable || candidate.streamableAt === undefined) break; if (now - Math.max(candidate.changedAt, candidate.streamableAt) < this.stabilityMs) break; delta += this.commit(candidate); - this.committed.push({ key: candidate.key, text: candidate.text }); - this.candidates.delete(index); + this.committed.push({ key: candidate.key, text: candidate.text, retired: false }); + this.candidates.delete(identity); } return delta; } finish(): { markdown: string; delta: string } { - this.assertCommittedPrefix(this.latest); + this.reconcileCommitted(this.latest); let delta = ""; - for (let index = this.committed.length; index < this.latest.length; index += 1) { - const segment = this.latest[index]!; + for (const segment of this.latest) { + if (this.isCommitted(segment)) continue; delta += this.commit(segment); - this.committed.push({ key: segment.key, text: segment.text }); + this.committed.push({ key: segment.key, text: segment.text, retired: false }); } this.candidates.clear(); return { markdown: this.markdown, delta }; } - private assertCommittedPrefix(segments: ChatGptMarkdownSegment[]): void { - if (segments.length < this.committed.length) { - throw new Error("ChatGPT removed a completed text block that was already streamed to Codex"); - } - for (let index = 0; index < this.committed.length; index += 1) { - const previous = this.committed[index]!; - const current = segments[index]!; - if (current.key !== previous.key || current.text !== previous.text) { + private reconcileCommitted(segments: ChatGptMarkdownSegment[]): void { + for (const previous of this.committed) { + if (previous.retired) continue; + const current = segments.find(segment => segment.key === previous.key); + if (!current) { + previous.retired = true; + continue; + } + if (current.text !== previous.text) { throw new Error("ChatGPT changed a completed text block that was already streamed to Codex"); } } } + private isCommitted(segment: ChatGptMarkdownSegment): boolean { + return this.committed.some(previous => !previous.retired + && previous.key === segment.key + && previous.text === segment.text); + } + + private identity(segment: ChatGptMarkdownSegment): string { + return `${segment.key}\u0000${segment.text}`; + } + private commit(segment: ChatGptMarkdownSegment): string { const block = this.transform(chatGptHtmlToMarkdown(segment.html)); if (!block) return ""; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index d6394715b..c09f6e6a5 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -630,7 +630,7 @@ test("unrelated ChatGPT alerts are not terminal", async () => { expect(fixture.pressed).toEqual([]); }); -function toolConfirmationPage(options: { disappearAfterReads?: number } = {}): { +function toolConfirmationPage(options: { disappearAfterReads?: number; korean?: boolean } = {}): { page: Page; pressed: string[]; } { @@ -639,31 +639,36 @@ function toolConfirmationPage(options: { disappearAfterReads?: number } = {}): { const pressed: string[] = []; const button = (name: string) => ({ last: () => button(name), + isVisible: async () => true, waitFor: async () => {}, press: async (key: string) => { pressed.push(`${name}:${key}`); visible = false; }, }); - const dialog = { - filter: ({ hasText }: { hasText: string }) => { - expect(hasText).toBe("Allow ChatGPT to use Codex Native?"); - return dialog; - }, - last: () => dialog, + const control = (name: string) => ({ + ...button(name), + last: () => control(name), isVisible: async () => { reads += 1; if (options.disappearAfterReads !== undefined && reads >= options.disappearAfterReads) visible = false; return visible; }, - getByRole: (_role: string, input: { name: string }) => button(input.name), - waitFor: async ({ state }: { state: string }) => { - expect(state).toBe("hidden"); - expect(visible).toBeFalse(); - }, - }; + }); return { - page: { locator: () => dialog } as unknown as Page, + page: { + getByRole: (_role: string, input: { name: string | RegExp }) => { + const candidates = options.korean ? ["허용하기", "거절하기"] : ["Allow once", "Deny"]; + const name = candidates.find(candidate => typeof input.name === "string" + ? input.name === candidate + : input.name.test(candidate)) ?? candidates[0]!; + return control(name); + }, + getByText: (name: string) => { + expect(name).toBe("Codex Native"); + return { last: () => ({ isVisible: async () => true }) }; + }, + } as unknown as Page, pressed, }; } @@ -689,6 +694,13 @@ test("explicit connector auto-approval still selects Allow once", async () => { expect(fixture.pressed).toEqual(["Allow once:Enter"]); }); +test("Korean connector auto-approval selects 허용하기", async () => { + const fixture = toolConfirmationPage({ korean: true }); + + expect(await resolveChatGptToolConfirmation(fixture.page, "Codex Native", true)).toBeTrue(); + expect(fixture.pressed).toEqual(["허용하기:Enter"]); +}); + test("browser preflight fails closed with Codex's native context-window error contract", () => { expect(() => assertChatGptWebInputWithinContextWindow(150_000, "medium")).toThrow( "150,000-token context window", diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 7ff16b102..02b36f35b 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -811,6 +811,19 @@ describe("ChatGPT outer-native harness v3", () => { ], 200)).toThrow("completed text block"); }); + test("keeps streamed commentary when ChatGPT replaces its DOM block after a tool call", () => { + const buffer = new ChatGptMarkdownBuffer(markdown => markdown, 100); + const commentary = [{ key: "0:root", html: "

Inspecting.

", text: "Inspecting.", streamable: true }]; + expect(buffer.observe(commentary, 0)).toBe(""); + expect(buffer.observe(commentary, 100)).toBe("Inspecting."); + expect(buffer.observe([], 150)).toBe(""); + + const answer = [{ key: "0:root", html: "

Done.

", text: "Done.", streamable: true }]; + expect(buffer.observe(answer, 200)).toBe(""); + expect(buffer.observe(answer, 300)).toBe("\n\nDone."); + expect(buffer.finish()).toEqual({ markdown: "Inspecting.\n\nDone.", delta: "" }); + }); + test("drops decorative HTML images without removing textual links", () => { const markdown = chatGptHtmlToMarkdown([ '

Source card: GitHub

',