Skip to content
Merged
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
16 changes: 7 additions & 9 deletions src/adapters/chatgpt-web/browser-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,13 @@ export async function resolveChatGptToolConfirmation(
timeoutMs = CHATGPT_TOOL_CONFIRMATION_TIMEOUT_MS,
onVisible?: () => Promise<void>,
): Promise<boolean> {
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;
Expand All @@ -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;
}

Expand Down
65 changes: 40 additions & 25 deletions src/adapters/chatgpt-web/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface ChatGptMarkdownCandidate extends ChatGptMarkdownSegment {
interface CommittedChatGptMarkdownSegment {
key: string;
text: string;
retired: boolean;
}

/**
Expand All @@ -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<number, ChatGptMarkdownCandidate>();
private readonly candidates = new Map<string, ChatGptMarkdownCandidate>();
private readonly committed: CommittedChatGptMarkdownSegment[] = [];
private latest: ChatGptMarkdownSegment[] = [];
private markdown = "";
Expand All @@ -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 ? {
Expand All @@ -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 "";
Expand Down
40 changes: 26 additions & 14 deletions tests/browser-worker-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
} {
Expand All @@ -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,
};
}
Expand All @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions tests/chatgpt-web-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Inspecting.</p>", 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: "<p>Done.</p>", 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([
'<p>Source card: <a href="https://github.com/example/repo"><img alt="GitHub" src="data:image/png;base64,AAAA"></a></p>',
Expand Down