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
paseo-bot[bot] 950e1f8f41 Keep an image's alt text and source when copying a selection from an assistant reply (#6181)
* fix(app): copy an assistant image as its Markdown, with alt text

Selecting across an image in an assistant reply copied the rendered img,
which has no alt text and, for data and workspace images, points at a
preview URL. The image frame now carries its Markdown source and alt text
the same way other Markdown elements declare their copy form.

Closes #6163

* fix(app): copy an image selected on its own as its Markdown

A selection inside the rendered image left the image's copy attributes
on the frame outside the range, so nothing was copied as Markdown.

* test(app): name the image-only selection in the copy spec

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 02:07:01 +02:00
paseo-bot[bot] 7a30305503 fix(opencode): agent goes idle after a v2 question is dismissed (#6177)
* fix(opencode): end the v2 turn when the user dismisses a question

OpenCode ends the execution when a question is dismissed but reports the
interruption with the reason "shutdown", which the turn tracker keeps open
waiting for a resume that never comes. The agent stayed running, and Stop,
reload and send were refused. A question dismissed from Paseo now lets that
interruption end the turn as canceled.

* test(opencode): cover the next message after a dismissed v2 question

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 01:06:55 +02:00
paseo-ai[bot] 3ad878b47f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 23:02:27 +00:00
11 changed files with 430 additions and 10 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
@@ -155,6 +155,19 @@ async function selectAssistantElementFromEndOf(
);
}
/** A drag over an image alone selects it inside its rendered frame. */
async function selectAssistantImage(page: Page): Promise<void> {
await assistantMessageBlocks(page)
.locator("img")
.evaluate((element) => {
const range = document.createRange();
range.selectNode(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
@@ -649,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.cleanup();
}
});
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
context,
page,
}) => {
const image =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-image-",
title: "Assistant selection copy image",
initialPrompt: "Render the image fixture.",
featureValues: {
mockAssistantResponse: [
"Before the image.",
"",
`![chart](${image})`,
"",
"After the image.",
].join("\n"),
},
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
await selectAssistantTextRange(page, "Before the image.", "After the image.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
`Before the image.\n\n![chart](${image})\n\nAfter the image.`,
);
await selectAssistantImage(page);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`);
} finally {
await agent.cleanup();
}
});
@@ -411,6 +411,20 @@ describe("assistant selection copy ranges", () => {
expect(content?.html).not.toContain("<pre>");
});
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
const message = mountFixture();
const frame = document.createElement("div");
frame.setAttribute("data-paseo-markdown-tag", "img");
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
frame.setAttribute("data-paseo-markdown-alt", "chart");
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
message.prepend(frame);
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
"![chart](https://example.test/chart.png)",
);
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
@@ -674,7 +688,9 @@ describe("assistant selection copy inside highlighted code", () => {
const message = mountHighlighted();
const image = document.createElement("div");
image.setAttribute("data-paseo-markdown-tag", "p");
image.innerHTML = '<div><img src="https://example.test/chart.png" alt="chart"></div>';
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
image.innerHTML =
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
const before = tokenText(message, "Before the block.");
@@ -7,10 +7,12 @@ import {
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_ALT_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_SRC_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
TRAILING_CODE_LINE_BREAKS,
@@ -293,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (tag === "p" || isTableStructure(tag)) {
// An image has no part to select, so touching it selects all of it.
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
@@ -430,7 +433,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
if (fragment.textContent) {
return true;
}
const visibleVoidSelector = ["br", "hr"]
const visibleVoidSelector = ["br", "hr", "img"]
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
.concat("img")
.join(",");
@@ -526,9 +529,13 @@ function restoreMarkdownElements(container: HTMLElement): void {
continue;
}
const semanticElement = document.createElement(tagName);
if (tagName !== "br") {
if (tagName !== "br" && tagName !== "img") {
semanticElement.append(...element.childNodes);
}
if (tagName === "img") {
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
}
if (tagName === "ol") {
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
if (start) {
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
/**
* Trailing line breaks, with any indentation that followed the last one.
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
} as const;
}
/**
* An image copies as the Markdown it came from. The rendered `img` points at a preview
* URL for data and workspace images, and carries no alt text of its own.
*/
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
return {
paseoMarkdownTag: "img",
paseoMarkdownSrc: source,
...(alt ? { paseoMarkdownAlt: alt } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
+5 -3
View File
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
markdownCopyDataSet,
markdownCopyImageDataSet,
markdownCopyOrderedListDataSet,
markdownCopyTableCellDataSet,
type MarkdownCopyInlineTag,
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
],
[containerStyle, imageSizeStyle],
);
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
if (image.status === "failed") {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
</View>
);
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
if (!binding) {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
);
}
return (
<View style={frameStyle}>
<View style={frameStyle} dataSet={copyDataSet}>
<Pressable
accessibilityLabel={t("composer.attachments.openImage")}
accessibilityRole="button"
@@ -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
);
}
});
@@ -1,4 +1,4 @@
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -210,6 +210,62 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
test("ends the turn when the user dismisses a question", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
let form: FormInfo | null = null;
harness.api.session.form.list = async () => (form ? [form] : []);
harness.api.session.form.cancel = async () => {
form = null;
harness.active = false;
// OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason.
harness.push({
id: "dismissed",
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("ask me something");
await expect.poll(() => harness.prompts).toHaveLength(1);
form = {
id: "question",
sessionID: "session",
title: "Color",
fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }],
};
harness.push({
id: "form",
created: 2,
type: "form.created",
data: { form },
});
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("question", { 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("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
const harness = new V2Harness();
harness.autoComplete = false;
@@ -33,6 +33,9 @@ export class SessionPermissions {
list() {
return [...this.pending.values()];
}
isQuestion(requestId: string, sessionID: string) {
return this.forms.get(requestId)?.sessionID === sessionID;
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
const form = this.forms.get(requestId);
if (form) {
@@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession {
}
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
await this.reconnectIfExited();
const dismissesQuestion =
response.behavior === "deny" && this.permissions.isQuestion(requestId, this.id);
await this.permissions.respondToPermission(requestId, response);
if (dismissesQuestion) this.turns.questionDismissed();
}
private async reconcileSnapshot() {
const [info, history] = await Promise.all([
@@ -60,6 +60,7 @@ interface Turn {
completion: Promise<void>;
settle(): void;
accepted: boolean;
questionDismissed: boolean;
}
export class SessionTurns {
@@ -112,6 +113,7 @@ export class SessionTurns {
settle,
accepted,
output,
questionDismissed: false,
};
this.turn = turn;
this.execution = null;
@@ -162,6 +164,13 @@ export class SessionTurns {
this.sequence = event.durable.seq;
this.requestReconciliation();
}
// 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.questionDismissed = true;
this.requestReconciliation();
}
private requestReconciliation() {
void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error));
}
@@ -216,7 +225,9 @@ export class SessionTurns {
this.sequence = execution.durable.seq;
if (
execution.type === "session.execution.started" ||
(execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown")
(execution.type === "session.execution.interrupted" &&
execution.data.reason === "shutdown" &&
!turn.questionDismissed)
)
return;
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.