Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 80833a376b test(app): wait for the focus hint padding before checking overlap 2026-10-06 08:35:49 +02:00
paseo-bot[bot] e85810e295 fix(app): keep the composer focus hint off the placeholder
The web composer draws its focus shortcut hint over the input's top-right
corner. When the placeholder and the hint did not fit on one line, as in
French at a phone width or with the longer desktop placeholder, the hint
was drawn on top of the end of the placeholder. The input now reserves
the hint's width as right padding while the hint shows, so the
placeholder wraps before it.

Closes #6191
2026-10-06 08:19:29 +02:00
paseo-bot[bot] e3db7c3397 fix(opencode): agent goes idle after a v2 tool permission is denied (#6188)
* fix(opencode): agent goes idle after a v2 tool permission is denied

OpenCode 2.0.20 ends the execution when a tool permission is rejected and
reports the interruption with reason "shutdown", the same reason it uses for
an execution it will resume after a server restart. The turn tracker only
treated "shutdown" as final after a dismissed question, so a denied tool
permission left the agent running with nothing pending, and Stop waited on a
turn that could never complete.

Treat any denied request owned by this session as ending the turn when the
following interruption reports "shutdown". Denials of child-session requests
do not interrupt the parent execution, so they are excluded.

* test(opencode): denying a child session's permission keeps the v2 turn resumable

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 06:08:14 +02:00
paseo-bot[bot] fe10c40b9a fix(app): keep a hover card open when a pressed button disables itself (#6185)
Clicking Refresh on a usage card in the context window details closed the
card within the close grace, with the pointer still on it. Refresh disables
itself while the request is pending, which drops the click's focus to the
document, and the hover card treated any focus leaving the card as a reason
to close.

Only keyboard focus keeps a hover card open, so only losing keyboard focus
now closes it. Whether focus is keyboard focus is read when it arrives,
because the element losing focus no longer matches :focus-visible during
focusout.

Closes #6176

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 05:03:59 +02:00
fxlelouarnandClaude Opus 5.5 b6e8ca8974 fix(app): correct the French translation (#6004)
The French locale was machine-translated word by word, without context:
"Back" became "Dos" (the body part), CI "Checks" became "Chèques" (bank
cheques), "Starting" became "Départ", "Connecting" became "De liaison",
and many interpolation placeholders were glued to neighboring words
("{{label}}copié", "Daemonindisponible").

Review every client-owned French string against its English source and
call site, apply one glossary across the file, and use French
typography (’, …, non-breaking space before high punctuation).

Add regression tests for the reported labels and for placeholders glued
to French words.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:45 +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
18 changed files with 1619 additions and 1175 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();
}
});
@@ -0,0 +1,113 @@
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,10 +268,16 @@ for (const theme of ["light", "dark"] as const) {
await expect(card).toBeVisible();
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
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!))] });
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 stays open for a keyboard-focused row", async ({ page }) => {
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
@@ -445,6 +445,11 @@ 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();
}
@@ -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"
@@ -82,6 +82,8 @@ 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) {
@@ -176,16 +178,20 @@ function WebHoverCard({
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Node;
const target = event.target as Element;
keyboardFocusedRef.current = target.matches(":focus-visible");
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)
) {
+27 -13
View File
@@ -608,18 +608,14 @@ 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">
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
{label}
</Text>
);
@@ -654,6 +650,25 @@ 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}>
@@ -674,7 +689,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
style={props.textInputStyle}
style={textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
editable={props.editable}
@@ -684,11 +699,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
{isFocusHintShown ? (
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
) : null}
</View>
);
}
@@ -1955,6 +1968,7 @@ 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,6 +183,23 @@ 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",
@@ -1,10 +1,11 @@
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";
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(
@@ -210,6 +211,172 @@ 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("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,6 +33,12 @@ 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
);
}
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 deniesOwnRequest =
response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id);
await this.permissions.respondToPermission(requestId, response);
if (deniesOwnRequest) this.turns.requestDenied();
}
private async reconcileSnapshot() {
const [info, history] = await Promise.all([
@@ -60,6 +60,7 @@ interface Turn {
completion: Promise<void>;
settle(): void;
accepted: boolean;
requestDenied: boolean;
}
export class SessionTurns {
@@ -112,6 +113,7 @@ export class SessionTurns {
settle,
accepted,
output,
requestDenied: 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 denies its permission or dismisses its question,
// but reports the interruption as "shutdown", the same reason as an execution it will resume.
requestDenied() {
if (!this.turn) return;
this.turn.requestDenied = 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.requestDenied)
)
return;
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.