Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ebae928a9c test(codex): reproduce agent prompts restarting auto-compaction (#5728)
A prompt sent with send_agent_prompt's default replaces the running turn.
While Codex auto-compacts, each one aborts the compaction and the next
turn starts another, and the replaced prompt never reaches Codex. The
steer variant compacts once and delivers every message.

The send_agent_prompt case fails on main; the steer case passes.
2026-10-06 03:41:51 +02:00
13 changed files with 1425 additions and 1449 deletions
@@ -1,113 +0,0 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function useLanguage(page: Page, language: string): Promise<void> {
await page.addInitScript(
({ key, language: value }) => {
localStorage.setItem(key, JSON.stringify({ language: value }));
},
{ key: APP_SETTINGS_KEY, language },
);
}
/**
* Lays the placeholder out the way the textarea does, in a mirror element with
* the same box and font, and reports whether any of its lines reach the hint.
*/
async function placeholderOverlaps(input: Locator, hint: Locator): Promise<boolean> {
const hintBox = await hint.boundingBox();
if (!hintBox) throw new Error("Focus hint has no layout box");
return input.evaluate((element, box) => {
const textarea = element as HTMLTextAreaElement;
const style = getComputedStyle(textarea);
const rect = textarea.getBoundingClientRect();
const mirror = document.createElement("div");
for (const property of [
"boxSizing",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"textIndent",
"wordSpacing",
] as const) {
mirror.style[property] = style[property];
}
Object.assign(mirror.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
borderStyle: "solid",
borderColor: "transparent",
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
visibility: "hidden",
});
const text = document.createTextNode(textarea.placeholder);
mirror.appendChild(text);
document.body.appendChild(mirror);
const range = document.createRange();
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects());
mirror.remove();
return lines.some(
(line) =>
line.right > box.x &&
line.left < box.x + box.width &&
line.bottom > box.y &&
line.top < box.y + box.height,
);
}, hintBox);
}
const FRENCH_LAYOUTS = [
{ name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" },
{
name: "desktop width",
width: 1024,
placeholder:
"Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills",
},
];
for (const layout of FRENCH_LAYOUTS) {
test.describe(`French composer at ${layout.name}`, () => {
test.use({ viewport: { width: layout.width, height: 844 } });
test("the focus hint does not cover the placeholder", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-hint-",
title: "Composer focus hint",
});
try {
await useLanguage(page, "fr");
await openAgentRoute(page, agent);
const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first();
await expect(input).toBeVisible();
await expect(input).toHaveAttribute("placeholder", layout.placeholder);
await input.evaluate((element) => (element as HTMLTextAreaElement).blur());
const hint = page.getByText(/pour saisir$/).first();
await expect(hint).toBeVisible();
// The input takes the hint's width on the render after the hint lays out.
await expect.poll(() => placeholderOverlaps(input, hint)).toBe(false);
} finally {
await agent.cleanup();
}
});
});
}
@@ -268,16 +268,10 @@ for (const theme of ["light", "dark"] as const) {
await expect(card).toBeVisible();
});
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
const refreshed = gate();
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
// Past the card's close grace, with the pointer still on the card.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
refreshed.open();
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
@@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
@@ -445,11 +445,6 @@ test.describe("Sidebar workspace list", () => {
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await page.keyboard.press("Tab");
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
@@ -82,8 +82,6 @@ function WebHoverCard({
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
const keyboardFocusedRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
@@ -178,20 +176,16 @@ function WebHoverCard({
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Element;
keyboardFocusedRef.current = target.matches(":focus-visible");
const target = event.target as Node;
if (trigger.contains(target)) {
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
};
// Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button
// that disables itself on press drops pointer focus while the pointer is still on the card.
const focusLeft = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (
keyboardFocusedRef.current &&
!trigger.contains(next) &&
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
) {
+13 -27
View File
@@ -608,14 +608,18 @@ function MessageInputOverlay({
}
function FocusHint({
visible,
focusInputKeys,
label,
onLayout,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
onLayout: (event: LayoutChangeEvent) => void;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
<Text style={styles.focusHintText} pointerEvents="none">
{label}
</Text>
);
@@ -650,25 +654,6 @@ interface ComposerTextSurfaceProps {
* state of this composer rather than a second one.
*/
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
const { isActiveComposer } = useComposerKeyboardScope();
const [focusHintWidth, setFocusHintWidth] = useState(0);
const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => {
setFocusHintWidth(event.nativeEvent.layout.width);
}, []);
const isFocusHintShown =
isActiveComposer &&
props.focusHintVisible &&
Boolean(props.focusInputKeys) &&
props.focusHintLabel.trim().length > 0;
// The hint sits over the input's top-right corner, so the placeholder wraps
// before it instead of running underneath.
const textInputStyle = useMemo(
() =>
isFocusHintShown
? [props.textInputStyle, { paddingRight: focusHintWidth }]
: props.textInputStyle,
[focusHintWidth, isFocusHintShown, props.textInputStyle],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
@@ -689,7 +674,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
style={textInputStyle}
style={props.textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
editable={props.editable}
@@ -699,9 +684,11 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
{isFocusHintShown ? (
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
) : null}
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
</View>
);
}
@@ -1968,7 +1955,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
position: "absolute",
top: 0,
right: 0,
paddingLeft: theme.spacing[2],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
opacity: 0.5,
-17
View File
@@ -183,23 +183,6 @@ describe("translation resources", () => {
expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님");
});
it("uses the French navigation and Git meanings for reported labels", () => {
expect(fr.common.back).toBe("Retour");
expect(fr.common.actions.back).toBe("Retour");
expect(fr.common.actions.copy).toBe("Copier");
expect(fr.common.states.starting).toBe("Démarrage…");
expect(fr.common.connectionStatus.connecting).toBe("Connexion…");
expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications");
expect(fr.sidebar.display.show.checks).toBe("Vérifications");
});
it("separates French interpolation placeholders from neighboring words", () => {
const glued = Object.entries(flattenStrings(fr)).filter(([, value]) =>
/\p{L}\{\{|\}\}\p{L}{2,}/u.test(value),
);
expect(glued).toEqual([]);
});
it("labels the immediate add-to-chat action without an ellipsis", () => {
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
});
File diff suppressed because it is too large Load Diff
@@ -163,7 +163,7 @@ export const pluginSettings = {
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
@@ -0,0 +1,248 @@
import { describe, expect, test, vi } from "vitest";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer, type ServerResponse } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { sendPromptToAgent } from "../agent-prompt.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function isCodexInstalled(): boolean {
try {
return (
execFileSync("which", ["codex"], { encoding: "utf8" }).trim().length > 0
);
} catch {
return false;
}
}
function sse(events: Array<Record<string, unknown>>): string {
return events
.map(
(event) =>
`event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`
)
.join("");
}
function assistantSse(id: string, text: string, totalTokens: number): string {
return sse([
{ type: "response.created", response: { id } },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: `msg-${id}`,
content: [{ type: "output_text", text }],
},
},
{
type: "response.completed",
response: {
id,
usage: {
input_tokens: totalTokens,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: totalTokens,
},
},
},
]);
}
function isCompactionRequest(body: string): boolean {
return body.includes("CONTEXT CHECKPOINT COMPACTION");
}
async function startServer() {
const requests: Array<{ compaction: boolean; body: string }> = [];
const heldCompactions: ServerResponse[] = [];
let index = 0;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end();
return;
}
const compaction = isCompactionRequest(body);
requests.push({ compaction, body });
res.statusCode = 200;
res.setHeader("content-type", "text/event-stream");
if (compaction) {
// Hold the summarization open, as a slow real compaction would be.
heldCompactions.push(res);
return;
}
index += 1;
// The first turn reports a context far above the auto-compact limit.
res.end(
assistantSse(
`resp-${index}`,
`reply ${index}`,
index === 1 ? 50_000 : 100
)
);
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("no address");
return {
url: `http://127.0.0.1:${address.port}`,
requests,
releaseCompactions() {
for (const res of heldCompactions.splice(0)) {
res.end(assistantSse("summary", "SUMMARY", 100));
}
},
close: () =>
new Promise<void>((resolve) => {
for (const res of heldCompactions.splice(0)) res.destroy();
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
function writeConfig(codexHome: string, serverUrl: string): void {
writeFileSync(
path.join(codexHome, "config.toml"),
`
model = "mock-model"
approval_policy = "on-request"
sandbox_mode = "read-only"
model_provider = "mock_provider"
model_auto_compact_token_limit = 10000
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "${serverUrl}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
`
);
}
async function waitFor(
check: () => boolean,
label: string,
timeoutMs = 15_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for ${label}`);
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
describe("Codex compaction and incoming prompts (local e2e)", () => {
for (const behavior of ["send_agent_prompt default", "steer"] as const) {
test.runIf(isCodexInstalled())(
`${behavior}: messages from other agents during auto-compaction do not restart it (#5728)`,
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-compact-cwd-"));
const codexHome = mkdtempSync(
path.join(os.tmpdir(), "codex-compact-home-")
);
const server = await startServer();
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
writeConfig(codexHome, server.url);
vi.stubEnv("CODEX_HOME", codexHome);
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined }
);
agentId = created.id;
const id = agentId;
await manager.runAgent(id, "first turn");
const send = (text: string) =>
sendPromptToAgent({
agentManager: manager,
agentStorage: storage,
agentId: id,
prompt: text,
...(behavior === "steer"
? { activeTurnBehavior: "steer" as const }
: {}),
logger,
});
const compactions = () =>
server.requests.filter((r) => r.compaction).length;
await send("second turn");
await waitFor(() => compactions() === 1, "first compaction request");
// Two more messages arrive from other agents while it compacts.
await send("update from agent A");
await new Promise((resolve) => setTimeout(resolve, 1500));
await send("update from agent B");
await new Promise((resolve) => setTimeout(resolve, 1500));
// Each message that replaced the turn aborted the compaction and started another.
expect(compactions()).toBe(1);
server.releaseCompactions();
await waitFor(
() => manager.getAgent(id)?.lifecycle === "idle",
"agent idle",
20_000
);
// Every message reaches Codex and the chat, once compaction finishes.
const delivered = server.requests
.filter((request) => !request.compaction)
.map((request) => request.body)
.join("\n");
const chat = manager
.getTimeline(id)
.flatMap((item) =>
item.type === "user_message" ? [item.text] : []
);
for (const text of [
"second turn",
"update from agent A",
"update from agent B",
]) {
expect(delivered).toContain(text);
expect(chat).toContain(text);
}
expect(compactions()).toBe(1);
} finally {
if (agentId && manager.getAgent(agentId))
await manager.closeAgent(agentId);
await storage.flush();
vi.unstubAllEnvs();
await server.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
60_000
);
}
});
@@ -5,7 +5,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { V2Api } from "./api.js";
function terminalEvents(events: AgentStreamEvent[]) {
return events.filter(
@@ -267,116 +266,6 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
test("ends the turn when the user denies a tool permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = null;
harness.api.permission.list = async () => (permission ? [permission] : []);
harness.api.permission.reply = async () => {
permission = null;
harness.active = false;
// OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason.
harness.push({
id: "denied",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "session", seq: 3, version: 1 },
data: { sessionID: "session", reason: "shutdown" },
});
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("run printf");
await expect.poll(() => harness.prompts).toHaveLength(1);
permission = {
id: "approval",
sessionID: "session",
action: "shell",
resources: ["printf TOOL_PERMISSION_QA"],
};
harness.push({
id: "asked",
created: 2,
type: "permission.asked",
data: permission,
});
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("approval", { behavior: "deny" });
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" });
await session.startTurn("next message");
await expect.poll(() => harness.prompts).toHaveLength(2);
harness.finishExecution();
await expect.poll(() => terminalEvents(events)).toHaveLength(2);
expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" });
} finally {
await session.close();
}
});
test("keeps the turn resumable after the user denies a child session's permission", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
const child = { ...harness.info, id: "child", parentID: "session" };
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = {
id: "child-approval",
sessionID: "child",
action: "shell",
resources: ["pwd"],
};
harness.api.permission.list = async (input) =>
permission && input.sessionID === "child" ? [permission] : [];
harness.api.permission.reply = async () => {
permission = null;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("delegate work");
await expect.poll(() => harness.prompts).toHaveLength(1);
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("child-approval", { behavior: "deny" });
harness.active = false;
harness.push({
id: "shutdown",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "session", seq: 3, version: 1 },
data: { sessionID: "session", reason: "shutdown" },
});
const reads = harness.activeReads;
harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
expect(terminalEvents(events)).toEqual([]);
harness.startExecution();
harness.finishExecution();
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
} finally {
await session.close();
}
});
test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
@@ -33,11 +33,8 @@ export class SessionPermissions {
list() {
return [...this.pending.values()];
}
isOwnedBy(requestId: string, sessionID: string) {
return (
this.forms.get(requestId)?.sessionID === sessionID ||
this.permissionOwners.get(requestId) === sessionID
);
isQuestion(requestId: string, sessionID: string) {
return this.forms.get(requestId)?.sessionID === sessionID;
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
const form = this.forms.get(requestId);
@@ -405,10 +405,10 @@ export class OpenCodeV2Session implements AgentSession {
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
await this.reconnectIfExited();
const deniesOwnRequest =
response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id);
const dismissesQuestion =
response.behavior === "deny" && this.permissions.isQuestion(requestId, this.id);
await this.permissions.respondToPermission(requestId, response);
if (deniesOwnRequest) this.turns.requestDenied();
if (dismissesQuestion) this.turns.questionDismissed();
}
private async reconcileSnapshot() {
const [info, history] = await Promise.all([
@@ -60,7 +60,7 @@ interface Turn {
completion: Promise<void>;
settle(): void;
accepted: boolean;
requestDenied: boolean;
questionDismissed: boolean;
}
export class SessionTurns {
@@ -113,7 +113,7 @@ export class SessionTurns {
settle,
accepted,
output,
requestDenied: false,
questionDismissed: false,
};
this.turn = turn;
this.execution = null;
@@ -164,11 +164,11 @@ export class SessionTurns {
this.sequence = event.durable.seq;
this.requestReconciliation();
}
// OpenCode ends the execution when the user denies its permission or dismisses its question,
// but reports the interruption as "shutdown", the same reason as an execution it will resume.
requestDenied() {
// OpenCode ends the execution when the user dismisses its question, but reports the
// interruption as "shutdown", the same reason as an execution it will resume.
questionDismissed() {
if (!this.turn) return;
this.turn.requestDenied = true;
this.turn.questionDismissed = true;
this.requestReconciliation();
}
private requestReconciliation() {
@@ -227,7 +227,7 @@ export class SessionTurns {
execution.type === "session.execution.started" ||
(execution.type === "session.execution.interrupted" &&
execution.data.reason === "shutdown" &&
!turn.requestDenied)
!turn.questionDismissed)
)
return;
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.