mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb96c02cc | ||
|
|
b9ac1d4982 | ||
|
|
d91171954b | ||
|
|
f232d23e9a | ||
|
|
68e722ec2e |
Generated
+10
@@ -39240,6 +39240,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"undici": "^7.30.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
@@ -39970,6 +39971,15 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"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": {
|
"packages/server/node_modules/vitest": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
hoverChatOutlinePrompt,
|
hoverChatOutlinePrompt,
|
||||||
chatOutlineRail,
|
chatOutlineRail,
|
||||||
movePointerOffChatOutline,
|
movePointerOffChatOutline,
|
||||||
|
observePromptIndexRequests,
|
||||||
pointAtChatOutlineRowEdge,
|
pointAtChatOutlineRowEdge,
|
||||||
pressEnterOnFocusedPrompt,
|
pressEnterOnFocusedPrompt,
|
||||||
splitCurrentPanelRight,
|
splitCurrentPanelRight,
|
||||||
withStreamingMarkdownOutline,
|
withStreamingMarkdownOutline,
|
||||||
expectReadingStreamedMarkdown,
|
expectReadingStreamedMarkdown,
|
||||||
} from "../support/helpers/chat-outline";
|
} from "../support/helpers/chat-outline";
|
||||||
|
import { createCreationScenario } from "../support/helpers/creation";
|
||||||
import {
|
import {
|
||||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||||
expectTimelinePromptNotMounted,
|
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,
|
expectConflictPreservesDraft,
|
||||||
discardTitle,
|
discardTitle,
|
||||||
returnAndReopenSettings,
|
returnAndReopenSettings,
|
||||||
|
returnToPluginsPage,
|
||||||
expectSettingsUnavailable,
|
expectSettingsUnavailable,
|
||||||
} from "../support/helpers/plugin-settings";
|
} from "../support/helpers/plugin-settings";
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
|
|||||||
await discardTitle(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.describe("compact plugin settings", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
|
|||||||
import { openSettingsSection } from "./settings";
|
import { openSettingsSection } from "./settings";
|
||||||
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
||||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
import { loadSessionMessageReaders } from "./new-workspace";
|
||||||
|
|
||||||
export async function withStreamingMarkdownOutline(
|
export async function withStreamingMarkdownOutline(
|
||||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||||
@@ -229,3 +230,22 @@ async function requireBoundingBox(
|
|||||||
}
|
}
|
||||||
return box;
|
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);
|
.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) {
|
async expectAgentTitle(title: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
|
|||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||||
import { getServerId } from "./server-id";
|
import { getServerId } from "./server-id";
|
||||||
|
import { expectAppRoute } from "./route-assertions";
|
||||||
|
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||||
|
|
||||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
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 page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
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) {
|
export async function expectSettingsUnavailable(page: Page) {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
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", () => {
|
it("accepts only the authoritative timeline epoch", () => {
|
||||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
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 type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||||
|
|
||||||
export function shouldAcceptPromptIndexEpoch(
|
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
|
||||||
timelineEpoch: string | null,
|
return timelineEpoch === indexEpoch;
|
||||||
indexEpoch: string,
|
|
||||||
): boolean {
|
|
||||||
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
|
|||||||
runtime.subscribeAgentTimeline.mockClear();
|
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 () => {
|
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||||
const second = deferred<{
|
const second = deferred<{
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ export function useChatOutline({
|
|||||||
|
|
||||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !enabled) {
|
if (!isWeb || !enabled || timelineEpoch === null) {
|
||||||
setIndex(null);
|
setIndex(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2071,6 +2071,7 @@ export const en = {
|
|||||||
offline: "Connect to this host to open plugin settings.",
|
offline: "Connect to this host to open plugin settings.",
|
||||||
update: "Update this host to use plugin settings.",
|
update: "Update this host to use plugin settings.",
|
||||||
unavailable: "This plugin settings screen is unavailable.",
|
unavailable: "This plugin settings screen is unavailable.",
|
||||||
|
backToPlugins: "Back to plugins",
|
||||||
},
|
},
|
||||||
trustedTitle: "Plugins are trusted code",
|
trustedTitle: "Plugins are trusted code",
|
||||||
trustedDescription:
|
trustedDescription:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const pluginSettings = {
|
|||||||
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
|
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
|
||||||
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
|
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
|
||||||
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
|
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
|
||||||
|
backToPlugins: "العودة إلى الإضافات",
|
||||||
},
|
},
|
||||||
title: "الإضافات",
|
title: "الإضافات",
|
||||||
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
|
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
|
||||||
@@ -72,6 +73,7 @@ export const pluginSettings = {
|
|||||||
offline: "Conéctate a este host para abrir los ajustes del plugin.",
|
offline: "Conéctate a este host para abrir los ajustes del plugin.",
|
||||||
update: "Actualiza este host para usar los ajustes del plugin.",
|
update: "Actualiza este host para usar los ajustes del plugin.",
|
||||||
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
|
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
|
||||||
|
backToPlugins: "Volver a plugins",
|
||||||
},
|
},
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
trustedTitle: "Los plugins son código de confianza",
|
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.",
|
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.",
|
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
|
||||||
unavailable: "Cet écran de paramètres du plugin est indisponible.",
|
unavailable: "Cet écran de paramètres du plugin est indisponible.",
|
||||||
|
backToPlugins: "Retour aux plugins",
|
||||||
},
|
},
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
trustedTitle: "Les plugins sont du code de confiance",
|
trustedTitle: "Les plugins sont du code de confiance",
|
||||||
@@ -180,6 +183,7 @@ export const pluginSettings = {
|
|||||||
offline: "このホストに接続してプラグイン設定を開いてください。",
|
offline: "このホストに接続してプラグイン設定を開いてください。",
|
||||||
update: "プラグイン設定を使用するには、このホストを更新してください。",
|
update: "プラグイン設定を使用するには、このホストを更新してください。",
|
||||||
unavailable: "このプラグイン設定画面は利用できません。",
|
unavailable: "このプラグイン設定画面は利用できません。",
|
||||||
|
backToPlugins: "プラグインに戻る",
|
||||||
},
|
},
|
||||||
title: "プラグイン",
|
title: "プラグイン",
|
||||||
trustedTitle: "プラグインは信頼済みコードです",
|
trustedTitle: "プラグインは信頼済みコードです",
|
||||||
@@ -234,6 +238,7 @@ export const pluginSettings = {
|
|||||||
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
|
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
|
||||||
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
|
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
|
||||||
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
|
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
|
||||||
|
backToPlugins: "플러그인으로 돌아가기",
|
||||||
},
|
},
|
||||||
title: "플러그인",
|
title: "플러그인",
|
||||||
trustedTitle: "플러그인은 신뢰하는 코드입니다",
|
trustedTitle: "플러그인은 신뢰하는 코드입니다",
|
||||||
@@ -287,6 +292,7 @@ export const pluginSettings = {
|
|||||||
offline: "Conecte-se a este host para abrir as configurações do plugin.",
|
offline: "Conecte-se a este host para abrir as configurações do plugin.",
|
||||||
update: "Atualize este host para usar 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.",
|
unavailable: "Esta tela de configurações do plugin está indisponível.",
|
||||||
|
backToPlugins: "Voltar para plugins",
|
||||||
},
|
},
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
trustedTitle: "Plugins são código confiável",
|
trustedTitle: "Plugins são código confiável",
|
||||||
@@ -341,6 +347,7 @@ export const pluginSettings = {
|
|||||||
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
|
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
|
||||||
update: "Обновите этот хост, чтобы использовать настройки плагина.",
|
update: "Обновите этот хост, чтобы использовать настройки плагина.",
|
||||||
unavailable: "Этот экран настроек плагина недоступен.",
|
unavailable: "Этот экран настроек плагина недоступен.",
|
||||||
|
backToPlugins: "Вернуться к плагинам",
|
||||||
},
|
},
|
||||||
title: "Плагины",
|
title: "Плагины",
|
||||||
trustedTitle: "Плагины — доверенный код",
|
trustedTitle: "Плагины — доверенный код",
|
||||||
@@ -395,6 +402,7 @@ export const pluginSettings = {
|
|||||||
offline: "连接到此主机以打开插件设置。",
|
offline: "连接到此主机以打开插件设置。",
|
||||||
update: "更新此主机以使用插件设置。",
|
update: "更新此主机以使用插件设置。",
|
||||||
unavailable: "此插件设置页面不可用。",
|
unavailable: "此插件设置页面不可用。",
|
||||||
|
backToPlugins: "返回插件",
|
||||||
},
|
},
|
||||||
title: "插件",
|
title: "插件",
|
||||||
trustedTitle: "插件是受信任的代码",
|
trustedTitle: "插件是受信任的代码",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, Text, View } from "react-native";
|
import { Platform, Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
import { ArrowLeft } from "lucide-react-native";
|
||||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
@@ -127,7 +128,30 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||||
export function PluginSettingsContent(props: SettingsIdentity) {
|
export function PluginSettingsContent({
|
||||||
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
|
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}
|
serverId={view.serverId}
|
||||||
pluginId={view.pluginId}
|
pluginId={view.pluginId}
|
||||||
screenId={view.screenId}
|
screenId={view.screenId}
|
||||||
|
onBackToPlugins={handleBackFromDetail}
|
||||||
|
showBackToPlugins={!isCompactLayout}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (view.kind === "host") {
|
if (view.kind === "host") {
|
||||||
|
|||||||
@@ -58,10 +58,23 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
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);
|
if (result.status !== "idle") throw new Error(result.error ?? result.status);
|
||||||
return result.lastMessage;
|
return result.lastMessage;
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ export interface DaemonClientTrace {
|
|||||||
|
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||||
activeTurnBehavior?: ActiveTurnBehavior;
|
activeTurnBehavior?: ActiveTurnBehavior;
|
||||||
images?: Array<{ data: string; mimeType: string }>;
|
images?: Array<{ data: string; mimeType: string }>;
|
||||||
attachments?: SendAgentMessageRequest["attachments"];
|
attachments?: SendAgentMessageRequest["attachments"];
|
||||||
|
|||||||
@@ -744,13 +744,17 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
|||||||
expect(updatedAgents).toEqual(["Updated"]);
|
expect(updatedAgents).toEqual(["Updated"]);
|
||||||
expect(agent.current()).toEqual(updatedAgent);
|
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));
|
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||||
expect(sendRequest).toMatchObject({
|
expect(sendRequest).toMatchObject({
|
||||||
type: "send_agent_message_request",
|
type: "send_agent_message_request",
|
||||||
agentId: "agent_sdk",
|
agentId: "agent_sdk",
|
||||||
text: "hello",
|
text: "hello",
|
||||||
messageId: "message-sdk",
|
messageId: "message-sdk",
|
||||||
|
activeTurnBehavior: "steer",
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.message(
|
ws.message(
|
||||||
@@ -768,6 +772,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
|||||||
|
|
||||||
const runPromise = agent.run("finish the task", {
|
const runPromise = agent.run("finish the task", {
|
||||||
messageId: "run-message-sdk",
|
messageId: "run-message-sdk",
|
||||||
|
activeTurnBehavior: "interrupt",
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
});
|
});
|
||||||
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
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",
|
agentId: "agent_sdk",
|
||||||
text: "finish the task",
|
text: "finish the task",
|
||||||
messageId: "run-message-sdk",
|
messageId: "run-message-sdk",
|
||||||
|
activeTurnBehavior: "interrupt",
|
||||||
});
|
});
|
||||||
ws.message(
|
ws.message(
|
||||||
sessionMessage({
|
sessionMessage({
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
WorkspaceProjectDescriptorPayload,
|
WorkspaceProjectDescriptorPayload,
|
||||||
RefreshProvidersSnapshotResponseMessage,
|
RefreshProvidersSnapshotResponseMessage,
|
||||||
SendAgentMessageRequest,
|
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
WorkspaceDescriptorPayload,
|
WorkspaceDescriptorPayload,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
@@ -55,6 +54,7 @@ import type {
|
|||||||
FetchAgentTimelineDirection,
|
FetchAgentTimelineDirection,
|
||||||
FetchAgentTimelinePayload,
|
FetchAgentTimelinePayload,
|
||||||
FetchAgentTimelineProjection,
|
FetchAgentTimelineProjection,
|
||||||
|
SendMessageOptions,
|
||||||
WaitForFinishResult,
|
WaitForFinishResult,
|
||||||
} from "./daemon-client.js";
|
} from "./daemon-client.js";
|
||||||
|
|
||||||
@@ -276,11 +276,7 @@ export interface PaseoAgentTimelineRefetchOptions {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaseoAgentSendOptions {
|
export type PaseoAgentSendOptions = SendMessageOptions;
|
||||||
messageId?: string;
|
|
||||||
images?: Array<{ data: string; mimeType: string }>;
|
|
||||||
attachments?: SendAgentMessageRequest["attachments"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
|
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"undici": "^7.30.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"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([
|
const OpencodeEditInputSchema = z.union([
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -229,12 +238,12 @@ const OpencodeEditInputSchema = z.union([
|
|||||||
newString: z.string().optional(),
|
newString: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.transform((value) => ({
|
.transform(toOpencodeEditInput),
|
||||||
filePath: value.filePath,
|
// OpenCode v2's edit tool names the file `path`.
|
||||||
oldString: nonEmptyString(value.oldString),
|
z
|
||||||
newString: nonEmptyString(value.newString),
|
.object({ path: z.string(), oldString: z.string(), newString: z.string() })
|
||||||
unifiedDiff: undefined,
|
.passthrough()
|
||||||
})),
|
.transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })),
|
||||||
ToolEditInputSchema,
|
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 { randomBytes } from "node:crypto";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { Agent } from "undici";
|
||||||
import { spawnProcess } from "../../../../../utils/spawn.js";
|
import { spawnProcess } from "../../../../../utils/spawn.js";
|
||||||
import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js";
|
import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js";
|
||||||
import {
|
import {
|
||||||
@@ -174,6 +175,9 @@ export class V2Runtime {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const processAbort = new AbortController();
|
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) =>
|
const exited = new Promise<Error>((resolve) =>
|
||||||
process.once("exit", (code) => {
|
process.once("exit", (code) => {
|
||||||
const error = new Error(`OpenCode helper server exited (${code})`);
|
const error = new Error(`OpenCode helper server exited (${code})`);
|
||||||
@@ -199,6 +203,7 @@ export class V2Runtime {
|
|||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped ??= (async () => {
|
stopped ??= (async () => {
|
||||||
await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 });
|
await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 });
|
||||||
|
await dispatcher.destroy();
|
||||||
const entry = await record;
|
const entry = await record;
|
||||||
if (entry) await managedProcesses?.remove(entry.id);
|
if (entry) await managedProcesses?.remove(entry.id);
|
||||||
})();
|
})();
|
||||||
@@ -243,7 +248,9 @@ export class V2Runtime {
|
|||||||
const signal = init?.signal
|
const signal = init?.signal
|
||||||
? AbortSignal.any([init.signal, processAbort.signal])
|
? AbortSignal.any([init.signal, processAbort.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;
|
const html = response.headers.get("content-type")?.includes("text/html") ?? false;
|
||||||
if (!response.ok || html) {
|
if (!response.ok || html) {
|
||||||
const requestUrl = request instanceof Request ? request.url : String(request);
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user