mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebae928a9c | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f |
+1
-1
@@ -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.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"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\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
} 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(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+248
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user