Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andMatt Cowger acb96c02cc fix(opencode): keep v2 turns running past five minutes
OpenCode answers session.wait only when the session goes idle, and the
helper client inherited fetch's five-minute header deadline, so every
longer turn failed with UND_ERR_HEADERS_TIMEOUT while OpenCode kept
working. The helper client now uses a dispatcher without a header
deadline; its requests end when the helper exits or the caller aborts.

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-09-29 09:22:54 +02:00
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
paseo-bot[bot] d91171954b fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 08:01:59 +02:00
paseo-bot[bot]andDaye f232d23e9a fix(client): let SDK callers choose what a send does to an active turn (#5605)
PaseoAgentSendOptions was a hand-copied subset of the daemon client's
SendMessageOptions and dropped activeTurnBehavior, so
agents.ref(id).send(text, { activeTurnBehavior: "steer" }) and run() failed
to typecheck even though the value is forwarded to the daemon. The public
type is now the options type send() passes through, so the two cannot
drift again.

Closes #4555

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Daye <2077152374@qq.com>
2026-09-29 06:53:14 +02:00
paseo-bot[bot] 68e722ec2e fix(app): request a chat outline only for a timeline the daemon has served (#5602)
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 06:16:30 +02:00
23 changed files with 305 additions and 25 deletions
+10
View File
@@ -39240,6 +39240,7 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.30.0",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -39970,6 +39971,15 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"packages/server/node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"packages/server/node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
@@ -17,12 +17,14 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -14,6 +14,7 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { loadSessionMessageReaders } from "./new-workspace";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
@@ -229,3 +230,22 @@ async function requireBoundingBox(
}
return box;
}
export async function observePromptIndexRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const agentIds: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "agent.timeline.list_prompts.request") {
agentIds.push(request.agentId);
}
});
});
return {
async waitForRequestFor(agentId: string) {
await expect.poll(() => agentIds).toContain(agentId);
},
requestedAgentIds: () => [...new Set(agentIds)],
};
}
@@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) {
)
.toBe(count);
},
async agentIds() {
await requests.settled();
return (await project.client.fetchAgents()).entries
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => {
it("accepts only the authoritative timeline epoch", () => {
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
});
});
@@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
export function shouldAcceptPromptIndexEpoch(
timelineEpoch: string | null,
indexEpoch: string,
): boolean {
return timelineEpoch === null || timelineEpoch === indexEpoch;
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
return timelineEpoch === indexEpoch;
}
/**
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
runtime.subscribeAgentTimeline.mockClear();
});
it("waits for a served timeline before requesting its prompt index", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
const viewportRef = createRef<StreamViewportHandle>();
const { rerender } = renderHook(
({ agentId, timelineEpoch }) =>
useChatOutline({
agentId,
serverId: "server-1",
timelineEpoch,
tail: [],
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
}),
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
);
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
});
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
const first = deferred<{ epoch: string; prompts: [] }>();
const second = deferred<{
@@ -77,8 +77,10 @@ export function useChatOutline({
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
// epoch, and its id names no agent the daemon knows.
useEffect(() => {
if (!isWeb || !enabled) {
if (!isWeb || !enabled || timelineEpoch === null) {
setIndex(null);
return;
}
+1
View File
@@ -2071,6 +2071,7 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
@@ -18,6 +18,7 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -72,6 +73,7 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
@@ -126,6 +128,7 @@ export const pluginSettings = {
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
@@ -180,6 +183,7 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -234,6 +238,7 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -287,6 +292,7 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.",
backToPlugins: "Voltar para plugins",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
@@ -341,6 +347,7 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -395,6 +402,7 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
+27 -3
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -127,7 +128,30 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
export function PluginSettingsContent({
onBackToPlugins,
showBackToPlugins,
...identity
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
const { t } = useTranslation();
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -1333,6 +1333,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/>
);
if (view.kind === "host") {
@@ -58,10 +58,23 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
try {
await client.connect();
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
activeTurnBehavior: "interrupt",
});
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
} finally {
await client.close();
}
}
+1
View File
@@ -425,6 +425,7 @@ export interface DaemonClientTrace {
export interface SendMessageOptions {
messageId?: string;
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
activeTurnBehavior?: ActiveTurnBehavior;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
+7 -1
View File
@@ -744,13 +744,17 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
const sendPromise = agent.send("hello", {
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(sendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "hello",
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
ws.message(
@@ -768,6 +772,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
@@ -776,6 +781,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
});
ws.message(
sessionMessage({
+2 -6
View File
@@ -24,7 +24,6 @@ import type {
ProjectPlacementPayload,
WorkspaceProjectDescriptorPayload,
RefreshProvidersSnapshotResponseMessage,
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
@@ -55,6 +54,7 @@ import type {
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
SendMessageOptions,
WaitForFinishResult,
} from "./daemon-client.js";
@@ -276,11 +276,7 @@ export interface PaseoAgentTimelineRefetchOptions {
requestId?: string;
}
export interface PaseoAgentSendOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
}
export type PaseoAgentSendOptions = SendMessageOptions;
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
+1
View File
@@ -141,6 +141,7 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.30.0",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -221,6 +221,15 @@ function deriveOpencodeTaskDetail(
};
}
function toOpencodeEditInput(value: { filePath: string; oldString?: string; newString?: string }) {
return {
filePath: value.filePath,
oldString: nonEmptyString(value.oldString),
newString: nonEmptyString(value.newString),
unifiedDiff: undefined,
};
}
const OpencodeEditInputSchema = z.union([
z
.object({
@@ -229,12 +238,12 @@ const OpencodeEditInputSchema = z.union([
newString: z.string().optional(),
})
.passthrough()
.transform((value) => ({
filePath: value.filePath,
oldString: nonEmptyString(value.oldString),
newString: nonEmptyString(value.newString),
unifiedDiff: undefined,
})),
.transform(toOpencodeEditInput),
// OpenCode v2's edit tool names the file `path`.
z
.object({ path: z.string(), oldString: z.string(), newString: z.string() })
.passthrough()
.transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })),
ToolEditInputSchema,
]);
@@ -0,0 +1,62 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import { V2Runtime } from "./runtime.js";
// Answers every request at once except session.wait, which it holds for WAIT_MS before answering,
// the way OpenCode holds the wait response until the session's agent loop is idle.
const FAKE_HELPER = `
import http from "node:http";
const server = http.createServer((request, response) => {
if (request.url.endsWith("/wait")) {
setTimeout(() => { response.statusCode = 204; response.end(); }, Number(process.env.WAIT_MS));
return;
}
response.setHeader("content-type", "application/json");
response.end("{}");
});
server.listen(0, "127.0.0.1", () => {
console.log("server listening on http://127.0.0.1:" + server.address().port);
});
`;
let root: string;
const defaultDispatcher = getGlobalDispatcher();
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-runtime-"));
vi.stubEnv("PASEO_HOME", path.join(root, "home"));
});
afterEach(async () => {
setGlobalDispatcher(defaultDispatcher);
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
});
test("session.wait outlasts the fetch header deadline until the helper reports the session idle", async () => {
// Stands in for undici's five-minute default, which ended every longer turn.
setGlobalDispatcher(new Agent({ headersTimeout: 200 }));
const helper = path.join(root, "fake-opencode.mjs");
await writeFile(helper, FAKE_HELPER);
const runtime = new V2Runtime({
logger: createTestLogger(),
settings: {
command: { mode: "replace", argv: [process.execPath, helper] },
env: { WAIT_MS: "2500" },
},
decorateEnv: async (env) => env,
});
try {
const connection = await runtime.acquire();
await expect(connection.client.session.wait({ sessionID: "ses_long" })).resolves.toBe(
undefined,
);
} finally {
await runtime.shutdown();
}
});
@@ -5,6 +5,7 @@ import type { V2Api } from "./api.js";
import { randomBytes } from "node:crypto";
import { mkdir } from "node:fs/promises";
import type { Logger } from "pino";
import { Agent } from "undici";
import { spawnProcess } from "../../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js";
import {
@@ -174,6 +175,9 @@ export class V2Runtime {
}),
});
const processAbort = new AbortController();
// session.wait answers only once the session is idle, so a header deadline would fail any
// turn that outlasts it. Requests end when the helper exits or the caller aborts instead.
const dispatcher = new Agent({ headersTimeout: 0 });
const exited = new Promise<Error>((resolve) =>
process.once("exit", (code) => {
const error = new Error(`OpenCode helper server exited (${code})`);
@@ -199,6 +203,7 @@ export class V2Runtime {
const stop = () => {
stopped ??= (async () => {
await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 });
await dispatcher.destroy();
const entry = await record;
if (entry) await managedProcesses?.remove(entry.id);
})();
@@ -243,7 +248,9 @@ export class V2Runtime {
const signal = init?.signal
? AbortSignal.any([init.signal, processAbort.signal])
: processAbort.signal;
const response = await fetch(request, { ...init, signal });
// Node's fetch accepts an undici dispatcher, which the DOM RequestInit type omits.
const requestInit: RequestInit & { dispatcher: Agent } = { ...init, signal, dispatcher };
const response = await fetch(request, requestInit);
const html = response.headers.get("content-type")?.includes("text/html") ?? false;
if (!response.ok || html) {
const requestUrl = request instanceof Request ? request.url : String(request);
@@ -0,0 +1,56 @@
import type { SessionMessageAssistant } from "@opencode/client";
import { expect, test } from "vitest";
import { V2Timeline } from "./timeline.js";
function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content,
};
}
test("a completed edit carries the replaced and replacement text", () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
type: "tool",
id: "call-edit",
name: "edit",
executed: false,
state: {
status: "completed",
input: { path: "a.txt", oldString: "beta", newString: "BETA" },
content: [{ type: "text", text: "Edited a.txt (1 replacement)" }],
metadata: {
files: [
{
file: "a.txt",
patch:
"Index: a.txt\n===================================================================\n--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
status: "modified",
additions: 1,
deletions: 1,
},
],
truncated: false,
},
},
time: { created: 2, ran: 2, completed: 2 },
},
]);
expect(new V2Timeline().messages([edit])).toMatchObject([
{
item: {
type: "tool_call",
name: "edit",
status: "completed",
detail: { type: "edit", filePath: "a.txt", oldString: "beta", newString: "BETA" },
},
},
]);
});