mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f09b3f697 | ||
|
|
106278adad |
+1
-1
@@ -118,7 +118,7 @@ appear in the plugin catalog. Editing one in development requires a daemon resta
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
A provider registration with `requiresConfiguration: true` participates only while its ID exists
|
||||
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
under `agents.providers`. Hoplite and fx use this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
|
||||
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
|
||||
provider does not enter executable resolution or catalog discovery.
|
||||
|
||||
@@ -112,21 +112,6 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
|
||||
agent directory and messages from the chat's own catch-up, and either can land first. So the timeline
|
||||
owner keeps an unsettled turn per chat: the turn its messages were captured during. It is set when a
|
||||
catch-up becomes owed while the turn is open, or when cached messages are painted that were saved
|
||||
while one was, and it is saved with those messages in the replica. It carries the turn's start time.
|
||||
While it is set and the agent reads idle, the chat keeps showing that running turn, with its loader
|
||||
and elapsed timer unchanged, and there is no separate catching-up footer. The commit that brings the
|
||||
chat current clears it, in the same store write as the rows, so the final messages and the completed
|
||||
footer land together. A failed catch-up clears it too, and the footer takes the true status. Losing
|
||||
the connection is not a failed catch-up: none can run or fail until the host returns, so the running
|
||||
footer stays beside "Reconnecting to host" and the catch-up on reconnect resolves it. Releasing it
|
||||
offline would put a completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar, tabs, the composer's stop and send controls) read the true status
|
||||
and never see this state. Live delivery needs no hold because `applyStreamEvent` commits queued rows
|
||||
before it applies turn liveness.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
|
||||
@@ -28,7 +28,7 @@ const ACP_PROVIDER = {
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { hopliteProvider?: boolean; plugins?: boolean };
|
||||
features?: { fxProvider?: boolean; hopliteProvider?: boolean; plugins?: boolean };
|
||||
} | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -58,6 +58,31 @@ test.describe("ACP provider catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("adds fx from the catalog with only a config entry", async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "fx-catalog-e2e",
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true);
|
||||
await client.patchDaemonConfig({ removeProviders: ["fx"] });
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await findAcpCatalogProvider(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
|
||||
await installAcpCatalogProvider(page, "fx");
|
||||
await expectProviderInstalledInSettings(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-installed.png") });
|
||||
expect((await client.getDaemonConfig()).config.providers?.fx).toEqual({ enabled: true });
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ removeProviders: ["fx"] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
|
||||
@@ -21,10 +21,7 @@ interface Box {
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
replyLength: number;
|
||||
footer: Box | null;
|
||||
footerText: string;
|
||||
elapsed: string | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
@@ -64,13 +61,10 @@ async function recordTurnEndFrames(
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footer = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map((element) => ({ element, box: boxOf(element) }))
|
||||
.filter((row): row is { element: Element; box: Box } => row.box !== null)
|
||||
.sort((left, right) => left.box.top - right.box.top)
|
||||
.at(-1);
|
||||
const elapsed =
|
||||
viewport?.querySelector('[data-testid="turn-working-elapsed"]')?.textContent ?? null;
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
@@ -80,16 +74,10 @@ async function recordTurnEndFrames(
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
const assistantRows = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? [],
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
replyLength: assistantRows.reduce((sum, row) => sum + (row.textContent?.length ?? 0), 0),
|
||||
footer: footer?.box ?? null,
|
||||
footerText: (footer?.element.textContent ?? "").replace(elapsed ?? "", ""),
|
||||
elapsed,
|
||||
footer: footers.at(-1) ?? null,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
@@ -159,7 +147,7 @@ function describeFrames(frames: TurnEndFrame[]): string {
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} timer=${frame.elapsed ?? "none"} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -194,192 +182,6 @@ function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | nu
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
interface ReturnedTurn {
|
||||
frames: TurnEndFrame[];
|
||||
reconnectedAt: number;
|
||||
finalReplyLength: number;
|
||||
}
|
||||
|
||||
// The agent finishes while the socket is down, then the catch-up is held so the idle status
|
||||
// reaches the chat first. Painted frames are sampled from the running footer until everything
|
||||
// has settled.
|
||||
async function returnAfterTurnFinished(input: {
|
||||
page: Page;
|
||||
agent: MockAgentWorkspace;
|
||||
prompt: string;
|
||||
leaveWhile: (gate: DaemonWebSocketGate) => Promise<void>;
|
||||
}): Promise<ReturnedTurn> {
|
||||
const { page, agent, prompt, leaveWhile } = input;
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
|
||||
await recordTurnEndFrames(page, { prompt, reply: "" });
|
||||
await leaveWhile(gate);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
const reconnectedAt = await page.evaluate(() => performance.now());
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await recordPaintsFor(page, 2_000);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-footer").last()).toBeVisible();
|
||||
await recordPaintsFor(page, 500);
|
||||
const frames = await stopRecording(page);
|
||||
return { frames, reconnectedAt, finalReplyLength: frames.at(-1)?.replyLength ?? 0 };
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
async function leaveWithOnlyThePromptOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, prompt);
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
}
|
||||
|
||||
async function leaveWithAPartialReplyOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
function elapsedSeconds(frame: TurnEndFrame): number {
|
||||
const units: Record<string, number> = { h: 3600, m: 60, s: 1 };
|
||||
return [...(frame.elapsed ?? "").matchAll(/(\d+)([hms])/g)].reduce(
|
||||
(total, [, value, unit]) => total + Number(value) * units[unit],
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectFooterUnchangedUntilMessagesLand(
|
||||
{ frames, reconnectedAt }: ReturnedTurn,
|
||||
record: string,
|
||||
): void {
|
||||
const held = frames.slice(
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt),
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt && !frame.running),
|
||||
);
|
||||
expect(
|
||||
held.length,
|
||||
`the running footer must hold until the messages land\n${record}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
new Set(held.map((frame) => `${frame.footer?.top}+${frame.footer?.height}`)).size,
|
||||
`the held footer must not move or resize\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
new Set(held.map((frame) => frame.footerText)).size,
|
||||
`the held footer's content must not change\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
held.filter((frame) => frame.elapsed === null).map((frame) => frame.at),
|
||||
`the timer must stay on screen\n${record}`,
|
||||
).toEqual([]);
|
||||
const seconds = held.map(elapsedSeconds);
|
||||
expect(seconds, `the timer must keep counting up\n${record}`).toEqual(
|
||||
seconds.toSorted((left, right) => left - right),
|
||||
);
|
||||
}
|
||||
|
||||
function expectReturnLandsWhole(returned: ReturnedTurn): void {
|
||||
const { frames, finalReplyLength } = returned;
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
const fromRunning = frames.slice(firstRunning);
|
||||
|
||||
expect(
|
||||
fromRunning.filter((frame) => frame.footer === null).map((frame) => frame.at),
|
||||
`the footer must never disappear\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
const completed = fromRunning.filter((frame) => !frame.running);
|
||||
expect(completed.length, record).toBeGreaterThan(0);
|
||||
// The first Markdown on a page paints an empty row until the web parser loads. Only text that
|
||||
// is present and short of the final reply is stale.
|
||||
expect(
|
||||
completed
|
||||
.filter((frame) => frame.replyLength > 0 && frame.replyLength !== finalReplyLength)
|
||||
.map((frame) => frame.at),
|
||||
`a completed footer must never sit under stale messages\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
expectFooterUnchangedUntilMessagesLand(returned, record);
|
||||
}
|
||||
|
||||
async function withFirstTurnAgent(
|
||||
model: string,
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-return-",
|
||||
title: "Turn return",
|
||||
model,
|
||||
});
|
||||
try {
|
||||
await run(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Returning to a turn that finished while away", () => {
|
||||
test("keeps the running footer until the messages land when only the prompt was on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("e2e-fast-stream", async (agent) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithOnlyThePromptOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the running footer until the messages land under a partial reply", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("ten-second-stream", async (agent) => {
|
||||
const prompt = "Return partial prompt";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithAPartialReplyOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
|
||||
@@ -79,9 +79,7 @@ test.describe("Schedules", () => {
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("hydrates schedule editing and opens a pristine creation form afterward", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
@@ -95,45 +93,29 @@ test.describe("Schedules", () => {
|
||||
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await test.step("edit form hydrates the saved model and controls", async () => {
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing edit leaves the new schedule form pristine", async () => {
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
@@ -183,4 +165,32 @@ test.describe("Schedules", () => {
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
});
|
||||
|
||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Pristine create ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -74,15 +74,12 @@ export function HorizontalScrollBoundaryShades({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
testIDPrefix = "horizontal-scroll-boundary",
|
||||
bottomInset = 1,
|
||||
}: {
|
||||
visible: boolean;
|
||||
backdrop: "surface" | "sidebar";
|
||||
leftStyle: AnimatedStyle<{ opacity: number }>;
|
||||
rightStyle: AnimatedStyle<{ opacity: number }>;
|
||||
testIDPrefix?: string;
|
||||
/** Set to 0 when the scrolled content's own border reaches the bottom edge and must fade too. */
|
||||
bottomInset?: number;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
|
||||
@@ -91,14 +88,14 @@ export function HorizontalScrollBoundaryShades({
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
testID={`${testIDPrefix}-left`}
|
||||
style={[styles.shade(bottomInset), styles.left, leftStyle]}
|
||||
style={[styles.shade, styles.left, leftStyle]}
|
||||
>
|
||||
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
testID={`${testIDPrefix}-right`}
|
||||
style={[styles.shade(bottomInset), styles.right, rightStyle]}
|
||||
style={[styles.shade, styles.right, rightStyle]}
|
||||
>
|
||||
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
|
||||
</Animated.View>
|
||||
@@ -107,12 +104,12 @@ export function HorizontalScrollBoundaryShades({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
shade: (bottomInset: number) => ({
|
||||
shade: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: bottomInset,
|
||||
bottom: 1,
|
||||
width: SHADE_WIDTH,
|
||||
}),
|
||||
},
|
||||
left: { left: 0 },
|
||||
right: { right: 0 },
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React, { act, useCallback, useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import {
|
||||
ComposerTrackBar,
|
||||
ComposerTrackPill,
|
||||
ComposerTrackRow,
|
||||
type ComposerTrackPillSegment,
|
||||
} from "./tracks";
|
||||
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
|
||||
|
||||
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
|
||||
|
||||
@@ -198,87 +192,6 @@ describe("composer track panel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const BAR_PILL_STYLE = { width: 120, height: 32 };
|
||||
const TRANSCRIPT_STYLE = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const;
|
||||
const NARROW_HOST = { position: "relative", width: 300, height: 120 } as const;
|
||||
const WIDE_HOST = { position: "relative", width: 400, height: 120 } as const;
|
||||
|
||||
function mountTrackBar({
|
||||
host,
|
||||
pills,
|
||||
}: {
|
||||
host: typeof NARROW_HOST | typeof WIDE_HOST;
|
||||
pills: number;
|
||||
}): void {
|
||||
mount(
|
||||
<MobilePanelsProvider>
|
||||
<View style={host}>
|
||||
<View testID="transcript" style={TRANSCRIPT_STYLE} />
|
||||
<ComposerTrackBar>
|
||||
{Array.from({ length: pills }, (_, index) => (
|
||||
<View key={index} testID={`bar-pill-${index}`} style={BAR_PILL_STYLE} />
|
||||
))}
|
||||
</ComposerTrackBar>
|
||||
</View>
|
||||
</MobilePanelsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function byTestID(testID: string): HTMLElement {
|
||||
const element = document.querySelector(`[data-testid="${testID}"]`);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`${testID} did not render`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/** The element that receives a touch landing at this point of the page. */
|
||||
function hitTest(x: number, y: number): Element | null {
|
||||
return document.elementFromPoint(x, y);
|
||||
}
|
||||
|
||||
describe("composer track bar", () => {
|
||||
// Android's horizontal ScrollView ignores the drag unless the scroller itself can be touched.
|
||||
// Browsers scroll either way, so this is the only part of that bug a browser can see.
|
||||
it("keeps the scroller touchable, so Android can drag the row", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
expect(getComputedStyle(byTestID("composer-track-scroll")).pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
it("stays inside the bar when the pills overflow it", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
const scroller = byTestID("composer-track-scroll");
|
||||
expect(scroller.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
byTestID("transcript").getBoundingClientRect().right,
|
||||
);
|
||||
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
|
||||
});
|
||||
|
||||
it("leaves the empty part of the row to the transcript", () => {
|
||||
mountTrackBar({ host: WIDE_HOST, pills: 1 });
|
||||
|
||||
const pillRect = byTestID("bar-pill-0").getBoundingClientRect();
|
||||
const transcript = byTestID("transcript");
|
||||
const emptyX = transcript.getBoundingClientRect().right - 40;
|
||||
expect(emptyX).toBeGreaterThan(pillRect.right);
|
||||
|
||||
expect(hitTest(emptyX, pillRect.top + pillRect.height / 2)).toBe(transcript);
|
||||
});
|
||||
|
||||
it("fades the full height of the row without taking its touches", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
const scroller = byTestID("composer-track-scroll").getBoundingClientRect();
|
||||
const shade = byTestID("composer-track-scroll-shade-right").getBoundingClientRect();
|
||||
expect(shade.bottom).toBe(scroller.bottom);
|
||||
|
||||
const underShade = hitTest(shade.left + shade.width / 2, shade.top + shade.height / 2);
|
||||
expect(byTestID("composer-track-scroll").contains(underShade)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer track pill status mark", () => {
|
||||
/** The mark is the glyph itself: the dot for a still state, the ring's carrier for running. */
|
||||
function mountMark(bucket: "running" | "failed"): { mark: HTMLElement; body: HTMLElement } {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
MENU_ITEM_HEIGHT,
|
||||
@@ -16,10 +15,6 @@ import {
|
||||
} from "@/components/ui/menu";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
HorizontalScrollBoundaryShades,
|
||||
useHorizontalScrollBoundary,
|
||||
} from "@/components/ui/horizontal-scroll-boundary";
|
||||
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures";
|
||||
@@ -43,35 +38,22 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
*/
|
||||
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
|
||||
const scrollGesture = usePanelAwareHorizontalScrollGesture();
|
||||
const scrollBoundary = useHorizontalScrollBoundary();
|
||||
return (
|
||||
<View style={styles.bar} pointerEvents="box-none">
|
||||
<View style={styles.trackViewport} pointerEvents="box-none">
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
<ScrollView
|
||||
testID="composer-track-scroll"
|
||||
style={styles.trackScroller}
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onLayout={scrollBoundary.onLayout}
|
||||
onContentSizeChange={scrollBoundary.onContentSizeChange}
|
||||
onScroll={scrollBoundary.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ScrollView>
|
||||
</GestureDetector>
|
||||
<HorizontalScrollBoundaryShades
|
||||
visible
|
||||
backdrop="surface"
|
||||
testIDPrefix="composer-track-scroll-shade"
|
||||
bottomInset={0}
|
||||
leftStyle={scrollBoundary.leftShadeStyle}
|
||||
rightStyle={scrollBoundary.rightShadeStyle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -411,13 +393,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
// Only as wide as its pills, so the rest of the row passes touches to the transcript. The
|
||||
// scroller can't use box-none for that: Android's horizontal ScrollView won't scroll with it.
|
||||
trackScroller: {
|
||||
alignSelf: "flex-start",
|
||||
// Web sizes a flex-start scroller to its content, so without a cap it overflows the bar.
|
||||
maxWidth: "100%",
|
||||
},
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "@/assets/acp-provider-icons";
|
||||
import type { HostFeatureName } from "@/runtime/host-features";
|
||||
|
||||
export interface AcpProviderCatalogEntry {
|
||||
id: string;
|
||||
@@ -8,7 +9,7 @@ export interface AcpProviderCatalogEntry {
|
||||
iconSvg: string | null;
|
||||
installLink: string;
|
||||
command: readonly [string, ...string[]];
|
||||
bundledProvider?: boolean;
|
||||
bundledProvider?: { readonly hostFeature: HostFeatureName };
|
||||
env?: Readonly<Record<string, string>>;
|
||||
params?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
@@ -179,6 +180,16 @@ const CATALOG_DATA = [
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "fx",
|
||||
bundledProvider: { hostFeature: "fxProvider" },
|
||||
title: "fx",
|
||||
description: "Vercel Labs' tiny, open, native coding agent",
|
||||
version: "manual",
|
||||
iconId: "fx",
|
||||
installLink: "https://fx.sh/docs/getting-started/installation",
|
||||
command: ["fx", "acp"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
@@ -241,7 +252,7 @@ const CATALOG_DATA = [
|
||||
},
|
||||
{
|
||||
id: "hoplite",
|
||||
bundledProvider: true,
|
||||
bundledProvider: { hostFeature: "hopliteProvider" },
|
||||
title: "Hoplite",
|
||||
description: "Cloud coding agent through the local hoplite acp bridge",
|
||||
version: "manual",
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("ACP provider catalog", () => {
|
||||
expect(findProvider("devin").command).toEqual(["devin", "acp"]);
|
||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||
expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]);
|
||||
expect(findProvider("fx").command).toEqual(["fx", "acp"]);
|
||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
||||
@@ -75,7 +76,7 @@ describe("ACP provider catalog", () => {
|
||||
command: ["hoplite", "acp"],
|
||||
installLink: "https://hoplite.sh/docs/cli#install",
|
||||
});
|
||||
expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({
|
||||
expect(buildAcpProviderConfigPatch(findProvider("hoplite"), () => true)).toEqual({
|
||||
providers: {
|
||||
hoplite: { enabled: true },
|
||||
},
|
||||
@@ -86,14 +87,31 @@ describe("ACP provider catalog", () => {
|
||||
expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"');
|
||||
});
|
||||
|
||||
it.each([undefined, false])(
|
||||
"rejects unsupported bundled Hoplite before producing a patch (%s)",
|
||||
(supported) => {
|
||||
expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow(
|
||||
"Update the host to add Hoplite.",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("offers fx as a bundled provider gated on its own host feature", () => {
|
||||
expect(findProvider("fx")).toMatchObject({
|
||||
title: "fx",
|
||||
installLink: "https://fx.sh/docs/getting-started/installation",
|
||||
});
|
||||
expect(findProvider("fx").iconSvg).toContain('fill="currentColor"');
|
||||
const supported: string[] = [];
|
||||
expect(
|
||||
buildAcpProviderConfigPatch(findProvider("fx"), (feature) => {
|
||||
supported.push(feature);
|
||||
return true;
|
||||
}),
|
||||
).toEqual({ providers: { fx: { enabled: true } } });
|
||||
expect(supported).toEqual(["fxProvider"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["hoplite", "Hoplite"],
|
||||
["fx", "fx"],
|
||||
])("rejects unsupported bundled %s before producing a patch", (id, title) => {
|
||||
const unsupported = () => false;
|
||||
expect(() => buildAcpProviderConfigPatch(findProvider(id), unsupported)).toThrow(
|
||||
`Update the host to add ${title}.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a catalog entry to the daemon provider config patch", () => {
|
||||
expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
import { ACP_PROVIDER_CATALOG, type AcpProviderCatalogEntry } from "@/data/acp-provider-catalog";
|
||||
import type { HostFeatureName } from "@/runtime/host-features";
|
||||
|
||||
export type AcpProviderCatalogItem = AcpProviderCatalogEntry;
|
||||
|
||||
@@ -10,11 +11,14 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] {
|
||||
|
||||
export function buildAcpProviderConfigPatch(
|
||||
entry: AcpProviderCatalogItem,
|
||||
supportsHopliteProvider = false,
|
||||
hostSupports: (feature: HostFeatureName) => boolean = () => false,
|
||||
): MutableDaemonConfigPatch {
|
||||
if (entry.bundledProvider) {
|
||||
// COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter.
|
||||
if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite.");
|
||||
// COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter.
|
||||
if (!hostSupports(entry.bundledProvider.hostFeature)) {
|
||||
throw new Error(`Update the host to add ${entry.title}.`);
|
||||
}
|
||||
return { providers: { [entry.id]: { enabled: true } } };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -86,7 +86,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentTimelineTurnPresentation,
|
||||
selectAgentTurnPresentation,
|
||||
type Agent,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
@@ -1411,7 +1411,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const turnPresentation = useSessionStore(
|
||||
useShallow((state) =>
|
||||
agentId
|
||||
? selectAgentTimelineTurnPresentation(state.sessions[serverId], agentId)
|
||||
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
|
||||
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -206,7 +206,6 @@ export class AgentStoreProjection {
|
||||
const store = useSessionStore.getState();
|
||||
const removeKey = withoutAgent(agentId);
|
||||
store.setAgentTimelineCursor(this.serverId, removeKey);
|
||||
store.setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
|
||||
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
||||
store.setAgentStreamTail(this.serverId, removeKey);
|
||||
store.clearAgentStreamHead(this.serverId, agentId);
|
||||
|
||||
@@ -216,19 +216,6 @@ function timeline(text = "Cached") {
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps the unsettled turn with the cached messages", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const cached = {
|
||||
...timeline(),
|
||||
unsettledTurn: { startedAt: new Date("2026-08-26T09:59:00.000Z"), turnId: "turn-1" },
|
||||
};
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", cached);
|
||||
await writer.flush();
|
||||
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(cached);
|
||||
});
|
||||
|
||||
function commitDirectory(
|
||||
cache: ReplicaCache,
|
||||
serverId: string,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type AgentToolCallData,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import type { UnsettledTurn } from "@/timeline/turn-liveness";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { clearLegacyReplicaCache } from "./legacy-cleanup";
|
||||
import {
|
||||
@@ -68,8 +67,6 @@ export interface CachedTimeline {
|
||||
items: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
/** Set when these messages were captured while a turn was open and not yet caught up. */
|
||||
unsettledTurn?: UnsettledTurn;
|
||||
}
|
||||
|
||||
const PERSIST_DELAY_MS = 1_000;
|
||||
@@ -359,9 +356,6 @@ const StoredTimelineSchema = z.strictObject({
|
||||
})
|
||||
.nullable(),
|
||||
hasOlder: z.boolean(),
|
||||
unsettledTurn: z
|
||||
.strictObject({ turnId: z.string().nullable(), startedAt: IsoDateSchema.nullable() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type StoredAgent = z.infer<typeof StoredAgentSchema>;
|
||||
@@ -418,16 +412,6 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu
|
||||
items: stored.items.map(deserializeTimelineItem),
|
||||
range: stored.range,
|
||||
hasOlder: stored.hasOlder,
|
||||
...(stored.unsettledTurn
|
||||
? {
|
||||
unsettledTurn: {
|
||||
turnId: stored.unsettledTurn.turnId,
|
||||
startedAt: stored.unsettledTurn.startedAt
|
||||
? new Date(stored.unsettledTurn.startedAt)
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -828,14 +812,6 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
|
||||
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
|
||||
: null,
|
||||
hasOlder: canPersistCoverage ? timeline.hasOlder : false,
|
||||
...(timeline.unsettledTurn
|
||||
? {
|
||||
unsettledTurn: {
|
||||
turnId: timeline.unsettledTurn.turnId,
|
||||
startedAt: timeline.unsettledTurn.startedAt?.toISOString() ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { selectHostFeature, useHostFeature } from "@/runtime/host-features";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
@@ -357,7 +358,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider");
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
@@ -444,7 +444,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
if (installingProviderId) return;
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider));
|
||||
await patchConfig(
|
||||
buildAcpProviderConfigPatch(entry, (feature) =>
|
||||
selectHostFeature(useSessionStore.getState(), serverId, feature),
|
||||
),
|
||||
);
|
||||
await refresh([entry.id]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
@@ -452,7 +456,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast],
|
||||
[installingProviderId, patchConfig, refresh, serverId, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,12 +56,10 @@ import {
|
||||
type WorkspaceAgentActivity,
|
||||
} from "@/utils/workspace-agent-activity";
|
||||
import {
|
||||
resolveTimelineTurnPresentation,
|
||||
resolveTurnPresentation,
|
||||
TURN_LIVENESS_IDLE,
|
||||
type TurnLiveness,
|
||||
type TurnPresentation,
|
||||
type UnsettledTurn,
|
||||
} from "@/timeline/turn-liveness";
|
||||
|
||||
export interface AgentRuntimeInfo {
|
||||
@@ -376,64 +374,6 @@ export function selectAgentTurnPresentation(
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn state for the chat's own timeline. Every other surface reads the true status above. */
|
||||
export function selectAgentTimelineTurnPresentation(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): TurnPresentation {
|
||||
return resolveTimelineTurnPresentation(
|
||||
selectAgentTurnPresentation(session, agentId),
|
||||
session?.agentTimelineUnsettledTurn.get(agentId),
|
||||
);
|
||||
}
|
||||
|
||||
/** The unsettled turn a chat's messages carry: the one open right now, or the one it holds. */
|
||||
export function selectAgentUnsettledTurn(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): UnsettledTurn | undefined {
|
||||
const turn = session?.agents.get(agentId)?.turn ?? session?.agentDetails.get(agentId)?.turn;
|
||||
if (turn?.phase === "open") return { turnId: turn.turnId, startedAt: turn.startedAt };
|
||||
return session?.agentTimelineUnsettledTurn.get(agentId);
|
||||
}
|
||||
|
||||
function withUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
turn: UnsettledTurn,
|
||||
): Map<string, UnsettledTurn> {
|
||||
const held = current.get(agentId);
|
||||
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
// Hosts without turn ids report null for every turn, so only the start time tells two turns apart.
|
||||
if (held?.turnId === turn.turnId && held?.startedAt?.getTime() === turn.startedAt?.getTime()) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(agentId, turn);
|
||||
return next;
|
||||
}
|
||||
|
||||
function withoutUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
): Map<string, UnsettledTurn> {
|
||||
if (!current.has(agentId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
}
|
||||
|
||||
// The messages that bring a chat current settle its turn in this same commit.
|
||||
function commitUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
commit: { synchronized: boolean; unsettledTurn?: UnsettledTurn },
|
||||
): Map<string, UnsettledTurn> {
|
||||
if (commit.synchronized) return withoutUnsettledTurn(current, agentId);
|
||||
if (commit.unsettledTurn) return withUnsettledTurn(current, agentId, commit.unsettledTurn);
|
||||
return current;
|
||||
}
|
||||
|
||||
function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
@@ -486,7 +426,6 @@ export interface SessionState {
|
||||
agentTasks: Map<string, TodoEntry[]>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineUnsettledTurn: Map<string, UnsettledTurn>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineHasNewer: Map<string, boolean>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
@@ -630,14 +569,8 @@ interface SessionStoreActions {
|
||||
newer: boolean;
|
||||
synchronized: boolean;
|
||||
acknowledgedClientMessageIds: string[];
|
||||
unsettledTurn?: UnsettledTurn;
|
||||
},
|
||||
) => void;
|
||||
setAgentTimelineUnsettledTurn: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
turn: UnsettledTurn | null,
|
||||
) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (
|
||||
@@ -741,7 +674,6 @@ function createInitialSessionState(
|
||||
agentTasks: new Map(),
|
||||
messageSubmissions: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
agentTimelineUnsettledTurn: new Map(),
|
||||
agentTimelineHasOlder: new Map(),
|
||||
agentTimelineHasNewer: new Map(),
|
||||
agentTimelineOlderFetchInFlight: new Map(),
|
||||
@@ -1523,11 +1455,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
const agentTimelineUnsettledTurn = commitUnsettledTurn(
|
||||
session.agentTimelineUnsettledTurn,
|
||||
agentId,
|
||||
state,
|
||||
);
|
||||
const tasks = latestTasksFromStream([...state.items, ...state.head]);
|
||||
const agentTasks = new Map(session.agentTasks);
|
||||
if (tasks.length > 0) agentTasks.set(agentId, tasks);
|
||||
@@ -1545,7 +1472,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentTimelineHasNewer: nextHasNewer,
|
||||
agentTimelineUnsettledTurn,
|
||||
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
||||
agentHistorySyncGeneration: nextSyncGeneration,
|
||||
messageSubmissions,
|
||||
@@ -1555,25 +1481,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineUnsettledTurn: (serverId, agentId, turn) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const current = session.agentTimelineUnsettledTurn;
|
||||
const next = turn
|
||||
? withUnsettledTurn(current, agentId, turn)
|
||||
: withoutUnsettledTurn(current, agentId);
|
||||
if (next === current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineUnsettledTurn: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -174,8 +174,6 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
onCatchUpEnded: () => undefined,
|
||||
reportError: () => undefined,
|
||||
onCatchUpOwed: () => undefined,
|
||||
onCatchUpSettled: () => undefined,
|
||||
});
|
||||
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
|
||||
// agent behind a restored tab on the daemon.
|
||||
|
||||
@@ -8,23 +8,15 @@ import {
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentTimelineTurnPresentation,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
} from "@/stores/session-store";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { reduceTurnLiveness, type TurnLivenessTransition } from "./turn-liveness";
|
||||
import type { TurnLivenessTransition } from "./turn-liveness";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
createViewedTimelineOwner,
|
||||
type TimelineReplicaStorage,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineOwner,
|
||||
type ViewedTimelineOwnerPorts,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
const SERVER_ID = "timeline-replica-host";
|
||||
@@ -55,14 +47,6 @@ function createOwner(
|
||||
agentId: string,
|
||||
transitions: readonly TurnLivenessTransition[],
|
||||
) => void = () => undefined,
|
||||
fetchPage: ViewedTimelineOwnerPorts["fetchPage"] = async () => ({
|
||||
hasNewer: false,
|
||||
endCursor: null,
|
||||
}),
|
||||
observe: ViewedTimelineOwnerPorts["observe"] = () => ({
|
||||
ready: Promise.resolve(),
|
||||
release: async () => undefined,
|
||||
}),
|
||||
): ViewedTimelineOwner {
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
@@ -76,9 +60,9 @@ function createOwner(
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
applyTurnLiveness,
|
||||
ports: {
|
||||
observe,
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
readCursor: () => undefined,
|
||||
fetchPage,
|
||||
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
reportError: () => undefined,
|
||||
},
|
||||
@@ -642,471 +626,6 @@ describe("turn boundaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the turn footer changes together with its messages", () => {
|
||||
const OPEN_TURN = { turnId: "turn-1", startedAt: new Date("2026-08-26T09:59:00.000Z") };
|
||||
|
||||
function setTurn(transition: TurnLivenessTransition): void {
|
||||
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
|
||||
const agent =
|
||||
agents.get(AGENT_ID) ??
|
||||
({
|
||||
id: AGENT_ID,
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
} as Agent);
|
||||
const next = new Map(agents);
|
||||
next.set(AGENT_ID, { ...agent, turn: reduceTurnLiveness(agent.turn, transition) });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const agentIsRunning = () => setTurn({ type: "snapshot", activeTurn: OPEN_TURN });
|
||||
const idleSnapshotArrives = () => setTurn({ type: "snapshot", activeTurn: null });
|
||||
|
||||
type Footer = "running" | "settled";
|
||||
function footer(): Footer {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
const turn = selectAgentTimelineTurnPresentation(session, AGENT_ID);
|
||||
return turn.isActive ? "running" : "settled";
|
||||
}
|
||||
function timerStart(): Date | null {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return selectAgentTimelineTurnPresentation(session, AGENT_ID).startedAt;
|
||||
}
|
||||
function replies(): string[] {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return [
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]
|
||||
.filter((entry) => entry.kind === "assistant_message")
|
||||
.map((entry) => entry.text);
|
||||
}
|
||||
|
||||
// A chat that was current when the connection dropped, holding `cachedReply` if given.
|
||||
function createChat(
|
||||
cachedReply: string | null,
|
||||
fetchPage?: ViewedTimelineOwnerPorts["fetchPage"],
|
||||
): ViewedTimelineOwner {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
|
||||
items: cachedReply ? [item("reply-1", cachedReply, 4)] : [],
|
||||
head: [],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
return createOwner(
|
||||
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
|
||||
undefined,
|
||||
fetchPage,
|
||||
);
|
||||
}
|
||||
|
||||
async function connectAndCatchUp(owner: ViewedTimelineOwner): Promise<void> {
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
owner.setConnected(true);
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
|
||||
}
|
||||
|
||||
function catchUpPage(input: {
|
||||
replyThroughSeq: number | null;
|
||||
reply?: string;
|
||||
}): TimelineResponsePayload {
|
||||
const end = input.replyThroughSeq ?? 4;
|
||||
return {
|
||||
requestId: "catch-up",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 },
|
||||
startCursor: input.reply ? { epoch: "epoch-1", seq: 5 } : null,
|
||||
endCursor: input.reply ? { epoch: "epoch-1", seq: end } : null,
|
||||
entries: input.reply
|
||||
? [
|
||||
{
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", text: input.reply },
|
||||
timestamp: "2026-08-26T10:00:02.000Z",
|
||||
seqStart: 5,
|
||||
seqEnd: end,
|
||||
sourceSeqRanges: [{ startSeq: 5, endSeq: end }],
|
||||
collapsed: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
};
|
||||
}
|
||||
|
||||
function recordCommits(): { commits: { footer: Footer; replies: string[] }[]; stop(): void } {
|
||||
const commits: { footer: Footer; replies: string[] }[] = [
|
||||
{ footer: footer(), replies: replies() },
|
||||
];
|
||||
const stop = useSessionStore.subscribe(() => {
|
||||
commits.push({ footer: footer(), replies: replies() });
|
||||
});
|
||||
return { commits, stop };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["only the prompt cached", null, ["Done."]],
|
||||
["a partial reply cached", "Half", ["HalfDone."]],
|
||||
])("snapshot before messages, %s: running until messages land", async (_name, cached, final) => {
|
||||
const owner = createChat(cached);
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.setConnected(false);
|
||||
expect(footer()).toBe("running");
|
||||
owner.setConnected(true);
|
||||
const recorder = recordCommits();
|
||||
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
recorder.stop();
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
expect(replies()).toEqual(final);
|
||||
// The messages and the settled footer arrive in one commit, with no state between.
|
||||
const previous = recorder.commits.at(-2);
|
||||
expect(previous?.footer).toBe("running");
|
||||
expect(previous?.replies).not.toEqual(final);
|
||||
expect(
|
||||
recorder.commits.filter(
|
||||
(commit) => commit.footer === "settled" && commit.replies.join() !== final.join(),
|
||||
),
|
||||
).toEqual([]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("messages before snapshot: running stays until the idle snapshot, then completes", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(replies()).toEqual(["HalfDone."]);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a turn that ended with no new messages settles when the empty catch-up lands", async () => {
|
||||
const owner = createChat("Done.");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
expect(replies()).toEqual(["Done."]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("keeps the true idle status for every surface outside the timeline", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect(selectAgentTurnPresentation(session, AGENT_ID).isActive).toBe(false);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("keeps the running turn's identity and timer start when the idle status arrives", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect(selectAgentTimelineTurnPresentation(session, AGENT_ID)).toEqual({
|
||||
isActive: true,
|
||||
isCancelling: false,
|
||||
startedAt: OPEN_TURN.startedAt,
|
||||
turnId: "turn-1",
|
||||
});
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("an agent still running on return keeps its running footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
agentIsRunning();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 6, reply: " more" }));
|
||||
expect(footer()).toBe("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("applies a snapshot at once when the chat is current", async () => {
|
||||
const owner = createChat("Done.");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a reconnect that finds nothing changed never leaves the settled footer", async () => {
|
||||
const owner = createChat("Done.");
|
||||
await connectAndCatchUp(owner);
|
||||
const recorder = recordCommits();
|
||||
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
|
||||
recorder.stop();
|
||||
|
||||
expect(recorder.commits.map((commit) => commit.footer)).not.toContain("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("catch-up failure gives the footer the true status", async () => {
|
||||
let failNext = false;
|
||||
const owner = createChat("Half", async () => {
|
||||
if (failNext) throw new Error("catch-up failed");
|
||||
return { hasNewer: false, endCursor: null };
|
||||
});
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
failNext = true;
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a catch-up that completes without a page still releases the footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.setConnected(true);
|
||||
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a chat closed while running and reopened after it finished keeps the running footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ host: "that reports turn ids", olderTurnId: "turn-1", newerTurnId: "turn-2" },
|
||||
{ host: "without turn ids", olderTurnId: null, newerTurnId: null },
|
||||
])("on a host $host, a newer turn replaces the held one so the timer follows it", async (ids) => {
|
||||
const newerTurn = { turnId: ids.newerTurnId, startedAt: new Date("2026-08-26T09:59:30.000Z") };
|
||||
const owner = createChat("Half");
|
||||
setTurn({
|
||||
type: "snapshot",
|
||||
activeTurn: { turnId: ids.olderTurnId, startedAt: OPEN_TURN.startedAt },
|
||||
});
|
||||
await connectAndCatchUp(owner);
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
setTurn({ type: "snapshot", activeTurn: newerTurn });
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
expect(timerStart()).toEqual(newerTurn.startedAt);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
function paintChat(unsettledTurn: CachedTimeline["unsettledTurn"]): ViewedTimelineOwner {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
idleSnapshotArrives();
|
||||
return createOwner({
|
||||
readTimeline: async () => ({
|
||||
agentId: AGENT_ID,
|
||||
items: [item("reply-1", "Half", 4)],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
...(unsettledTurn ? { unsettledTurn } : {}),
|
||||
}),
|
||||
commitTimeline: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it("first paint from cache: shows the running footer in the first frame when the cached messages were from an open turn", async () => {
|
||||
const owner = paintChat({ turnId: "turn-1", startedAt: OPEN_TURN.startedAt });
|
||||
const recorder = recordCommits();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
recorder.stop();
|
||||
|
||||
expect(recorder.commits.find((commit) => commit.replies.length > 0)?.footer).toBe("running");
|
||||
expect(timerStart()).toEqual(OPEN_TURN.startedAt);
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
async function subscriptionRefusedDuringCacheRead(): Promise<{
|
||||
owner: ViewedTimelineOwner;
|
||||
finishCacheRead(): void;
|
||||
}> {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
idleSnapshotArrives();
|
||||
let resolveRead: (timeline: CachedTimeline) => void = () => undefined;
|
||||
const owner = createOwner(
|
||||
{
|
||||
readTimeline: () => new Promise((resolve) => (resolveRead = resolve)),
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
() => ({
|
||||
ready: Promise.reject(new Error("subscription refused")),
|
||||
release: async () => undefined,
|
||||
}),
|
||||
);
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
owner.setConnected(true);
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
return {
|
||||
owner,
|
||||
finishCacheRead: () =>
|
||||
resolveRead({
|
||||
agentId: AGENT_ID,
|
||||
items: [item("reply-1", "Half", 4)],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
unsettledTurn: { turnId: "turn-1", startedAt: OPEN_TURN.startedAt },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("first paint from cache: a catch-up that failed during the cache read keeps the true status", async () => {
|
||||
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
|
||||
|
||||
finishCacheRead();
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: a catch-up owed again during the cache read keeps the running footer", async () => {
|
||||
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
|
||||
owner.setConnected(false);
|
||||
|
||||
finishCacheRead();
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: shows the settled footer at once when the cached messages were from a finished turn", async () => {
|
||||
const owner = paintChat(undefined);
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: records the open turn with the messages and drops it once the turn ends", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
applySynced(AGENT_ID, 8);
|
||||
agentIsRunning();
|
||||
const commits: CachedTimeline[] = [];
|
||||
const owner = createOwner(
|
||||
{
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
|
||||
},
|
||||
(_agentId, transitions) => {
|
||||
for (const transition of transitions) setTurn(transition);
|
||||
},
|
||||
);
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "Working" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: 9,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:01.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
expect(commits.at(-1)?.unsettledTurn).toEqual({
|
||||
turnId: "turn-1",
|
||||
startedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
timestamp: new Date("2026-08-26T10:00:02.000Z"),
|
||||
});
|
||||
|
||||
expect(commits.at(-1)?.unsettledTurn).toBeUndefined();
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create handoff lifetime", () => {
|
||||
const DRAFT_ID = "draft-1";
|
||||
|
||||
|
||||
@@ -54,16 +54,6 @@ export interface TurnPresentation {
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn a chat's messages were captured during, kept until the chat has caught up past it.
|
||||
* While it is held and the agent reads idle, the messages on screen may be older than the turn's
|
||||
* end, so the chat cannot claim the turn is finished yet.
|
||||
*/
|
||||
export interface UnsettledTurn {
|
||||
turnId: string | null;
|
||||
startedAt: Date | null;
|
||||
}
|
||||
|
||||
export function resolveTurnPresentation(
|
||||
liveness: TurnLiveness,
|
||||
hasActiveSubmission: boolean,
|
||||
@@ -84,24 +74,6 @@ export function resolveTurnPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chat shows for its turn. It differs from the true status only while the agent has
|
||||
* finished and the chat's messages have not caught up: the chat keeps showing the running turn, so
|
||||
* the completed footer can land with the messages it belongs to.
|
||||
*/
|
||||
export function resolveTimelineTurnPresentation(
|
||||
turn: TurnPresentation,
|
||||
unsettledTurn: UnsettledTurn | undefined,
|
||||
): TurnPresentation {
|
||||
if (turn.isActive || !unsettledTurn) return turn;
|
||||
return {
|
||||
isActive: true,
|
||||
isCancelling: false,
|
||||
startedAt: unsettledTurn.startedAt,
|
||||
turnId: unsettledTurn.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
function closeTurn(current: TurnLiveness, turnId: string | null): TurnLiveness {
|
||||
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
const targetsDifferentIdentifiedTurn =
|
||||
|
||||
@@ -142,8 +142,6 @@ class TimelineWorld {
|
||||
status: this.sync.getAgentTimelineStatus(agentId),
|
||||
});
|
||||
},
|
||||
onCatchUpOwed: () => undefined,
|
||||
onCatchUpSettled: () => undefined,
|
||||
prepare: async (agentId) => {
|
||||
this.cacheRequests.push(agentId);
|
||||
this.cacheRequestWaiters.shift()?.(agentId);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentUnsettledTurn,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
type AgentTimelineState,
|
||||
@@ -41,7 +40,6 @@ async function prepareCachedTimeline(input: {
|
||||
agentId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
isTurnSettled: () => boolean;
|
||||
}): Promise<CachedTimeline | undefined> {
|
||||
const before = useSessionStore.getState().sessions[input.serverId];
|
||||
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
|
||||
@@ -89,7 +87,6 @@ async function prepareCachedTimeline(input: {
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
|
||||
unsettledTurn: input.isTurnSettled() ? undefined : stored.unsettledTurn,
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
@@ -99,16 +96,11 @@ export interface TimelineReplica {
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined;
|
||||
timelineUpdated(agentId: string): void;
|
||||
catchUpOwed(agentId: string): void;
|
||||
catchUpSettled(agentId: string): void;
|
||||
}
|
||||
|
||||
class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
|
||||
private readonly preparations = new Map<string, Promise<void>>();
|
||||
// A cache read in flight still carries the turn as it was saved. Once the catch-up has
|
||||
// settled, that turn must not come back with the cached messages.
|
||||
private readonly settledDuringPreparation = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
@@ -131,7 +123,6 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
this.settledDuringPreparation.delete(agentId);
|
||||
}
|
||||
});
|
||||
this.preparations.set(agentId, preparation);
|
||||
@@ -144,7 +135,6 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
agentId,
|
||||
storage: this.storage,
|
||||
prepareAgent: this.prepareAgent,
|
||||
isTurnSettled: () => this.settledDuringPreparation.has(agentId),
|
||||
});
|
||||
if (!stored) return;
|
||||
if (stored.range) {
|
||||
@@ -171,21 +161,8 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
|
||||
range: timeline.status === "synced" ? timeline.range : null,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
unsettledTurn: selectAgentUnsettledTurn(session, agentId),
|
||||
});
|
||||
}
|
||||
|
||||
catchUpOwed(agentId: string): void {
|
||||
this.settledDuringPreparation.delete(agentId);
|
||||
const store = useSessionStore.getState();
|
||||
const openTurn = selectAgentUnsettledTurn(store.sessions[this.serverId], agentId);
|
||||
if (openTurn) store.setAgentTimelineUnsettledTurn(this.serverId, agentId, openTurn);
|
||||
}
|
||||
|
||||
catchUpSettled(agentId: string): void {
|
||||
if (this.preparations.has(agentId)) this.settledDuringPreparation.add(agentId);
|
||||
useSessionStore.getState().setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
@@ -263,7 +240,6 @@ function commitProcessedTimeline(input: {
|
||||
});
|
||||
}
|
||||
store.markAgentHistorySynchronized(serverId, agentId);
|
||||
if (synchronized) store.setAgentTimelineUnsettledTurn(serverId, agentId, null);
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
@@ -354,10 +330,6 @@ export interface ViewedTimelineSyncPorts {
|
||||
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
|
||||
*/
|
||||
onCatchUpEnded(agentId: string): void;
|
||||
/** The chat stopped receiving every row, so a catch-up is owed before it is current again. */
|
||||
onCatchUpOwed(agentId: string): void;
|
||||
/** The owed catch-up reached current or failed. The chat can take the true turn status. */
|
||||
onCatchUpSettled(agentId: string): void;
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
@@ -386,7 +358,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
|
||||
export type ViewedTimelineOwnerPorts = Omit<
|
||||
ViewedTimelineSyncPorts,
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded" | "onCatchUpOwed" | "onCatchUpSettled"
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
|
||||
>;
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
@@ -424,8 +396,6 @@ export function createViewedTimelineOwner(input: {
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
onCatchUpEnded: (agentId) =>
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
onCatchUpOwed: (agentId) => input.replica.catchUpOwed(agentId),
|
||||
onCatchUpSettled: (agentId) => input.replica.catchUpSettled(agentId),
|
||||
});
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
@@ -453,7 +423,6 @@ export function createViewedTimelineOwner(input: {
|
||||
if (turnLiveness.length === 0) return;
|
||||
streamQueue.flushAgent(agentId);
|
||||
input.applyTurnLiveness(agentId, turnLiveness);
|
||||
input.replica.timelineUpdated(agentId);
|
||||
},
|
||||
flushStreamAgent(agentId) {
|
||||
streamQueue.flushAgent(agentId);
|
||||
@@ -568,7 +537,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||
const wasRetrying = manualRetries.delete(agentId);
|
||||
ports.onCatchUpSettled(agentId);
|
||||
ports.onCatchUpEnded(agentId);
|
||||
if (wasPending || hadError || wasRetrying) notifyListeners();
|
||||
};
|
||||
@@ -577,7 +545,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
let changed = false;
|
||||
for (const agentId of agentIds) {
|
||||
ports.onCatchUpSettled(agentId);
|
||||
if (manualRetries.delete(agentId)) changed = true;
|
||||
if (visibilityCatchUpPending.delete(agentId)) changed = true;
|
||||
if (visibilityCatchUpErrors.get(agentId) !== message) {
|
||||
@@ -588,11 +555,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (changed) notifyListeners();
|
||||
};
|
||||
|
||||
const markCatchUpOwed = (agentId: string) => {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
ports.onCatchUpOwed(agentId);
|
||||
};
|
||||
|
||||
const cancelCatchUp = (agentId: string) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
catchUps.delete(agentId);
|
||||
@@ -787,7 +749,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (options.resetCatchUpStatus) {
|
||||
for (const agentId of nextDesired) {
|
||||
if (!visibilityCatchUpPending.has(agentId)) {
|
||||
markCatchUpOwed(agentId);
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
statusChanged = true;
|
||||
}
|
||||
if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true;
|
||||
@@ -806,13 +768,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
visibilityCatchUpPending.delete(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
ports.onCatchUpOwed(agentId);
|
||||
released.push(agentId);
|
||||
}
|
||||
}
|
||||
for (const agentId of nextDesired) {
|
||||
if (!desired.includes(agentId)) {
|
||||
markCatchUpOwed(agentId);
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
ensureCacheLoaded(agentId);
|
||||
@@ -869,7 +830,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
}
|
||||
for (const agentId of visibleAgentIds()) {
|
||||
if (visibilityCatchUpErrors.has(agentId)) continue;
|
||||
markCatchUpOwed(agentId);
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
startCatchUp(agentId, { supersede: true });
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@@ -695,15 +695,16 @@ class AcpRuntime {
|
||||
async configure(
|
||||
changes: Extract<ProviderInput, { type: "session.configure" }>["changes"],
|
||||
): Promise<void> {
|
||||
const ordered: AcpConfigChange[] = [];
|
||||
if (changes.model !== undefined) ordered.push({ target: "model", value: changes.model });
|
||||
if (changes.mode !== undefined) ordered.push({ target: "mode", value: changes.mode });
|
||||
const requested: AcpConfigChange[] = [];
|
||||
if (changes.model !== undefined) requested.push({ target: "model", value: changes.model });
|
||||
if (changes.mode !== undefined) requested.push({ target: "mode", value: changes.mode });
|
||||
if (changes.thinkingOption !== undefined) {
|
||||
ordered.push({ target: "thinking", value: changes.thinkingOption });
|
||||
requested.push({ target: "thinking", value: changes.thinkingOption });
|
||||
}
|
||||
for (const [id, value] of Object.entries(changes.settings ?? {})) {
|
||||
ordered.push({ target: "setting", id, value });
|
||||
requested.push({ target: "setting", id, value });
|
||||
}
|
||||
const ordered = this.inAgentOrder(requested);
|
||||
const previousModes = this.modes ? structuredClone(this.modes) : null;
|
||||
const previousOptions = structuredClone(this.configOptions);
|
||||
this.configTransaction = true;
|
||||
@@ -769,14 +770,36 @@ class AcpRuntime {
|
||||
}
|
||||
|
||||
private async applyInitialConfig(config: ProviderSessionConfig): Promise<void> {
|
||||
if (config.model) await this.applyConfigChange({ target: "model", value: config.model });
|
||||
if (config.mode) await this.applyConfigChange({ target: "mode", value: config.mode });
|
||||
const requested: AcpConfigChange[] = [];
|
||||
if (config.model) requested.push({ target: "model", value: config.model });
|
||||
if (config.mode) requested.push({ target: "mode", value: config.mode });
|
||||
if (config.thinkingOption) {
|
||||
await this.applyConfigChange({ target: "thinking", value: config.thinkingOption });
|
||||
requested.push({ target: "thinking", value: config.thinkingOption });
|
||||
}
|
||||
for (const [id, value] of Object.entries(config.settings)) {
|
||||
await this.applyConfigChange({ target: "setting", id, value });
|
||||
requested.push({ target: "setting", id, value });
|
||||
}
|
||||
for (const change of this.inAgentOrder(requested)) await this.applyConfigChange(change);
|
||||
}
|
||||
|
||||
// One option can reset another: choosing an fx provider also selects that provider's saved
|
||||
// model, so a model applied first would be lost. Agents list the option that decides first.
|
||||
private inAgentOrder(changes: readonly AcpConfigChange[]): AcpConfigChange[] {
|
||||
const position = (change: AcpConfigChange): number => {
|
||||
const configId = this.configIdFor(change);
|
||||
const index = this.configOptions.findIndex((option) => option.id === configId);
|
||||
return index === -1 ? this.configOptions.length : index;
|
||||
};
|
||||
return changes.toSorted((left, right) => position(left) - position(right));
|
||||
}
|
||||
|
||||
private configIdFor(change: AcpConfigChange): string | undefined {
|
||||
if (change.target === "setting") return change.id;
|
||||
if (change.target === "model") return modelOption(this.configOptions)?.id;
|
||||
if (change.target === "thinking") {
|
||||
return this.configOptions.find((option) => option.category === "thought_level")?.id;
|
||||
}
|
||||
return this.configOptions.find((option) => option.category === "mode")?.id;
|
||||
}
|
||||
|
||||
private async applyConfigChange(change: AcpConfigChange): Promise<void> {
|
||||
@@ -804,13 +827,7 @@ class AcpRuntime {
|
||||
this.modes = { ...this.modes, currentModeId: change.value };
|
||||
return;
|
||||
}
|
||||
let category: "model" | "thought_level" | null = null;
|
||||
if (change.target === "model") category = "model";
|
||||
if (change.target === "thinking") category = "thought_level";
|
||||
const configId =
|
||||
change.target === "setting"
|
||||
? change.id
|
||||
: this.configOptions.find((option) => option.category === category)?.id;
|
||||
const configId = this.configIdFor(change);
|
||||
if (!configId) {
|
||||
throw new Error(`ACP session does not expose ${change.target} configuration`);
|
||||
}
|
||||
@@ -1375,7 +1392,7 @@ function toProviderConfigState(
|
||||
modes: NewSessionResponse["modes"],
|
||||
options: readonly SessionConfigOption[],
|
||||
): ProviderConfigState {
|
||||
const model = options.find((option) => option.category === "model");
|
||||
const model = modelOption(options);
|
||||
const thinking = options.find((option) => option.category === "thought_level");
|
||||
return {
|
||||
model: selectedValue(model),
|
||||
@@ -1393,11 +1410,23 @@ function toProviderConfigState(
|
||||
label: option.label,
|
||||
})),
|
||||
settings: options
|
||||
.filter((option) => option.category !== "model" && option.category !== "thought_level")
|
||||
.filter(
|
||||
(option) =>
|
||||
option !== model &&
|
||||
option.category !== "thought_level" &&
|
||||
!(modes && option.category === "mode"),
|
||||
)
|
||||
.map(toProviderSetting),
|
||||
};
|
||||
}
|
||||
|
||||
// ACP's category is a grouping hint, so one category can hold several controls. fx files its
|
||||
// provider first, then the model, Fast, and Ultrafast under "model".
|
||||
function modelOption(options: readonly SessionConfigOption[]): SessionConfigOption | undefined {
|
||||
const candidates = options.filter((option) => option.category === "model");
|
||||
return candidates.find((option) => option.id === "model") ?? candidates[0];
|
||||
}
|
||||
|
||||
function toProviderCatalog(
|
||||
modes: NewSessionResponse["modes"],
|
||||
options: readonly SessionConfigOption[],
|
||||
|
||||
@@ -1155,6 +1155,99 @@ lines.on("line", (line) => {
|
||||
},
|
||||
);
|
||||
|
||||
it("selects the model control when an agent files several controls under the model category", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
let model = "small";
|
||||
const modes = { currentModeId: "auto", availableModes: [{ id: "auto", name: "Auto" }, { id: "ask", name: "Ask" }] };
|
||||
const options = () => [
|
||||
{ id: "provider", name: "Provider", category: "model", type: "select", currentValue: "gateway", options: [{ name: "Gateway", value: "gateway" }, { name: "Local", value: "local" }] },
|
||||
{ id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "Small", value: "small" }, { name: "Large", value: "large" }] },
|
||||
{ id: "mode", name: "Session Mode", category: "mode", type: "select", currentValue: "auto", options: [{ name: "Auto", value: "auto" }, { name: "Ask", value: "ask" }] },
|
||||
{ id: "fast", name: "Fast Mode", category: "model", type: "select", currentValue: "false", options: [{ name: "Off", value: "false" }, { name: "On", value: "true" }] },
|
||||
];
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes, configOptions: options() } });
|
||||
else if (message.method === "session/set_config_option") {
|
||||
if (message.params.configId === "model") model = message.params.value;
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } });
|
||||
}
|
||||
});`);
|
||||
const { connection, events } = await connect(executable, [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
]);
|
||||
await connection.send({ type: "catalog", requestId: "catalog-1" });
|
||||
await waitForEvent(events, (event) => event.type === "catalog");
|
||||
expect(events.find((event) => event.type === "catalog")).toMatchObject({
|
||||
catalog: {
|
||||
defaultModel: "small",
|
||||
models: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "large", label: "Large" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-1",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "large" },
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "request.completed" && event.requestId === "configure-1",
|
||||
);
|
||||
const config = events.findLast((event) => event.type === "session.config");
|
||||
expect(config).toMatchObject({ config: { model: "large" } });
|
||||
if (config?.type !== "session.config") throw new Error("Expected session.config");
|
||||
expect(config.config.settings?.map((setting) => setting.id)).toEqual(["provider", "fast"]);
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("applies opening configuration in the order the agent lists its options", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
let provider = "first";
|
||||
let model = "first-saved";
|
||||
const saved = { first: "first-saved", second: "second-saved" };
|
||||
const select = (id, currentValue, values) => ({ id, name: id, category: "model", type: "select", currentValue, options: values.map((value) => ({ name: value, value })) });
|
||||
const options = () => [
|
||||
select("provider", provider, ["first", "second"]),
|
||||
select("model", model, ["first-saved", "second-saved", "second-chosen"]),
|
||||
];
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", configOptions: options() } });
|
||||
else if (message.method === "session/set_config_option") {
|
||||
if (message.params.configId === "provider") { provider = message.params.value; model = saved[provider]; }
|
||||
if (message.params.configId === "model") model = message.params.value;
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } });
|
||||
}
|
||||
});`);
|
||||
const { connection, events } = await connect(executable, [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
]);
|
||||
const input = openInput({ provider: "second" });
|
||||
|
||||
await connection.send({ ...input, config: { ...input.config, model: "second-chosen" } });
|
||||
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
expect(events.findLast((event) => event.type === "session.config")).toMatchObject({
|
||||
config: { model: "second-chosen" },
|
||||
});
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("gives discovery transformers the discovered session configuration facade", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
|
||||
@@ -813,13 +813,14 @@ describe("checkout PR schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([undefined, false, true])(
|
||||
"accepts optional Hoplite capability (%s)",
|
||||
(hopliteProvider) => {
|
||||
const features = hopliteProvider === undefined ? {} : { hopliteProvider };
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features })
|
||||
.features,
|
||||
).toEqual(features);
|
||||
},
|
||||
);
|
||||
test.each(
|
||||
["hopliteProvider", "fxProvider"].flatMap((name) =>
|
||||
[undefined, false, true].map((value) => [name, value] as const),
|
||||
),
|
||||
)("accepts optional bundled provider capability (%s: %s)", (name, value) => {
|
||||
const features = value === undefined ? {} : { [name]: value };
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features })
|
||||
.features,
|
||||
).toEqual(features);
|
||||
});
|
||||
|
||||
@@ -4065,6 +4065,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providerRemoval: z.boolean().optional(),
|
||||
// COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter.
|
||||
hopliteProvider: z.boolean().optional(),
|
||||
// COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter.
|
||||
fxProvider: z.boolean().optional(),
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: z.boolean().optional(),
|
||||
// COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02.
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
||||
"dirac",
|
||||
"factory-droid",
|
||||
"fast-agent",
|
||||
"fx",
|
||||
"gemini",
|
||||
"gjc",
|
||||
"glm-acp-agent",
|
||||
|
||||
@@ -13511,6 +13511,9 @@ describe("agent options", () => {
|
||||
/** What the session says it ended up with after the next apply. */
|
||||
ended: Record<string, unknown> = {};
|
||||
openOptionsPatch: Record<string, unknown> = {};
|
||||
runtimeModel: string | null = null;
|
||||
/** The model the next apply moves the session to, as a provider's connection option does. */
|
||||
movesModelTo: string | null = null;
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
if (this.rejectWith) throw this.rejectWith;
|
||||
@@ -13518,13 +13521,20 @@ describe("agent options", () => {
|
||||
this.applyGate = null;
|
||||
await gate;
|
||||
this.applied.push(options);
|
||||
if (this.movesModelTo) this.runtimeModel = this.movesModelTo;
|
||||
return this.ended;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
const info = await super.getRuntimeInfo();
|
||||
return this.runtimeModel ? { ...info, model: this.runtimeModel } : info;
|
||||
}
|
||||
}
|
||||
|
||||
class OptionsClient extends TestAgentClient {
|
||||
session: OptionsSession | null = null;
|
||||
openOptionsPatch: Record<string, unknown> = {};
|
||||
runtimeModel: string | null = null;
|
||||
|
||||
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
|
||||
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
||||
@@ -13534,6 +13544,7 @@ describe("agent options", () => {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new OptionsSession(config);
|
||||
this.session.openOptionsPatch = this.openOptionsPatch;
|
||||
this.session.runtimeModel = this.runtimeModel;
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@@ -13541,14 +13552,21 @@ describe("agent options", () => {
|
||||
async function createOptionsAgent(
|
||||
providerOptions?: ProviderOptions,
|
||||
openOptionsPatch: Record<string, unknown> = {},
|
||||
opened: { model?: string; runtimeModel?: string } = {},
|
||||
) {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
client.openOptionsPatch = openOptionsPatch;
|
||||
client.runtimeModel = opened.runtimeModel ?? null;
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, ...(providerOptions ? { providerOptions } : {}) },
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
...(opened.model ? { model: opened.model } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
@@ -13637,6 +13655,33 @@ describe("agent options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("an option that moves the agent to another model reopens on that model", async () => {
|
||||
const { manager, client, storage, agentId } = await createOptionsAgent(
|
||||
undefined,
|
||||
{},
|
||||
{ model: "first-connection-model" },
|
||||
);
|
||||
client.session!.movesModelTo = "second-connection-model";
|
||||
|
||||
await manager.setAgentOptions(agentId, { web_search: true });
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.model).toBe("second-connection-model");
|
||||
await manager.flush();
|
||||
expect((await storage.get(agentId))?.config?.model).toBe("second-connection-model");
|
||||
});
|
||||
|
||||
test("an option that leaves the model alone keeps the model the user chose", async () => {
|
||||
const { manager, agentId } = await createOptionsAgent(
|
||||
undefined,
|
||||
{},
|
||||
{ model: "alias", runtimeModel: "canonical" },
|
||||
);
|
||||
|
||||
await manager.setAgentOptions(agentId, { web_search: true });
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.model).toBe("alias");
|
||||
});
|
||||
|
||||
test("a patch key is resolved against the configured defaults, merging nested objects", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const client = new OptionsClient();
|
||||
|
||||
@@ -2506,9 +2506,17 @@ export class AgentManager {
|
||||
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
||||
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||
const modelBefore = agent.runtimeInfo?.model;
|
||||
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||
// Changes the provider made on its own while applying are older than its final answer.
|
||||
await this.drainSessionEvents(agentId);
|
||||
await this.refreshRuntimeInfo(agent, { emit: false });
|
||||
// An option can choose the model too, as an fx connection does. Reopening the agent on
|
||||
// the earlier preference would send that model to the new connection.
|
||||
const modelAfter = agent.runtimeInfo?.model;
|
||||
if (agent.config.model && modelAfter && modelAfter !== modelBefore) {
|
||||
agent.config.model = modelAfter;
|
||||
}
|
||||
}
|
||||
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Local ACP peer shaped like `fx acp` 0.0.16; no fx install or credentials.
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
fs.appendFileSync(process.env.FX_TEST_RECORD, "launch\n");
|
||||
let model = "fixture-small";
|
||||
const modes = {
|
||||
currentModeId: "auto",
|
||||
availableModes: [
|
||||
{ id: "auto", name: "Auto" },
|
||||
{ id: "ask", name: "Ask" },
|
||||
{ id: "full-access", name: "Full access" },
|
||||
],
|
||||
};
|
||||
const select = (id, name, category, currentValue, values) => ({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: values.map((value) => ({ value, name: value })),
|
||||
});
|
||||
const configOptions = () => [
|
||||
select("provider", "Provider", "model", "gateway", ["gateway", "codex", "grok"]),
|
||||
select("model", "Model", "model", model, ["fixture-small", "fixture-large"]),
|
||||
select("mode", "Session Mode", "mode", modes.currentModeId, ["auto", "ask", "full-access"]),
|
||||
select("fast", "Fast Mode", "model", "false", ["false", "true"]),
|
||||
];
|
||||
const send = (message) =>
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
let result = {};
|
||||
if (request.method === "initialize")
|
||||
result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };
|
||||
if (request.method === "session/new")
|
||||
result = { sessionId: "fx-fixture", modes, configOptions: configOptions() };
|
||||
if (request.method === "session/set_config_option") {
|
||||
if (request.params.configId === "model") model = request.params.value;
|
||||
result = { configOptions: configOptions() };
|
||||
}
|
||||
if (request.method === "session/prompt") result = { stopReason: "end_turn" };
|
||||
send({ id: request.id, result });
|
||||
});
|
||||
@@ -164,6 +164,46 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test("bundled fx requires a config entry and lists models instead of its providers", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-fx-optin-"));
|
||||
const record = path.join(root, "launches.txt");
|
||||
await writeFile(record, "");
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(undefined, ["fx-provider"]),
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.15.3",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true);
|
||||
const before = await client.getProvidersSnapshot();
|
||||
expect(before.entries.some((entry) => entry.provider === "fx")).toBe(false);
|
||||
expect(await readFile(record, "utf8")).toBe("");
|
||||
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
fx: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve(import.meta.dirname, "fx-fixture.cjs")],
|
||||
env: { FX_TEST_RECORD: record },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { models } = await client.listProviderModels("fx", { cwd: root });
|
||||
expect(models.map((model) => model.id)).toEqual(["fixture-small", "fixture-large"]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("bundled Hoplite requires a config entry and removal survives restart", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-"));
|
||||
const record = path.join(root, "launches.txt");
|
||||
|
||||
@@ -8,6 +8,7 @@ export const builtinPlugins = [
|
||||
"codex-usage-source",
|
||||
"copilot-usage-source",
|
||||
"cursor-usage-source",
|
||||
"fx-provider",
|
||||
"grok-usage-source",
|
||||
"hoplite-provider",
|
||||
"kimi-usage-source",
|
||||
|
||||
@@ -1153,6 +1153,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
|
||||
expect(serverInfo.features?.providersSnapshotCwd).toBe(true);
|
||||
expect(serverInfo.features?.hopliteProvider).toBe(false);
|
||||
expect(serverInfo.features?.fxProvider).toBe(false);
|
||||
expect(serverInfo.features?.pluginLogs).toBe(true);
|
||||
expect(serverInfo.features?.workspaceMarkUnread).toBe(true);
|
||||
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
|
||||
|
||||
@@ -2187,6 +2187,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: true,
|
||||
hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"),
|
||||
// COMPAT(fxProvider): added after v0.15.3, remove after 2027-05-09 once host floor includes the bundled adapter.
|
||||
fxProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("fx"),
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: true,
|
||||
// COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="166.241 0 155.861 155.75" fill="currentColor"><path d="M237.892 0C243.178 8.73262e-05 249.383 1.41857 253.025 3.07422L255.093 4.01367L250.076 18.6299L247.677 17.7461C244.901 16.7234 241.938 15.7998 238.491 15.7998C234.984 15.7998 232.787 16.5601 231.08 18.3213C229.231 20.2297 227.629 23.6395 226.229 29.7578L226.227 29.7617L226.228 29.7627L225.136 34.8506H241.669L260.432 34.9502L260.694 34.9512L260.842 35.1689L278.854 61.6328L296.736 34.9502H320.866L291.676 76.7402L322.102 119.75H299.331L299.182 119.546L241.144 40.4775L239.35 49.4004H222.069L205.688 127.208C203.927 135.709 201.185 142.838 196.777 147.866C192.27 153.008 186.201 155.75 178.342 155.75C174.181 155.75 170.746 155.109 167.909 154.107L166.241 153.519V137.182L166.899 137.4L169.531 138.278C172.182 139.162 174.414 139.8 177.142 139.8C178.528 139.8 179.703 139.526 180.732 138.98C181.764 138.434 182.677 137.6 183.52 136.442C185.304 133.989 186.716 130.135 187.897 124.672L203.76 49.4004H189.874L191.759 39.4375L192.039 39.3457L206.818 34.4668L208.154 28.6416C210.516 18.2065 213.772 10.9381 218.708 6.31836C223.737 1.61217 230.127 3.08752e-05 237.892 0ZM273.987 99.084L260.073 120.25H234.541L261.003 82.0215L273.987 99.084Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { createFxProvider } from "./server/provider.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createFxProvider());
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "fx-provider", "requirements": { "paseo": ">=0.11.0" } }
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AcpToolCallSnapshot } from "@getpaseo/plugin/server/acp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileToolTransformer } from "./file-tools.js";
|
||||
|
||||
const context = { sessionId: "session-1" };
|
||||
|
||||
// Shapes captured from `fx acp` 0.0.16.
|
||||
function fxToolCall(name: string, input: AcpToolCallSnapshot["input"]): AcpToolCallSnapshot {
|
||||
return {
|
||||
id: "call_1",
|
||||
name,
|
||||
title: "Writing",
|
||||
kind: "edit",
|
||||
status: "pending",
|
||||
input,
|
||||
output: null,
|
||||
locations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function transform(toolCall: AcpToolCallSnapshot): AcpToolCallSnapshot {
|
||||
return fileToolTransformer.toolCall!(toolCall, context);
|
||||
}
|
||||
|
||||
describe("fx file tool calls", () => {
|
||||
it("presents write_file as a write to its path", () => {
|
||||
expect(
|
||||
transform(fxToolCall("write_file", { path: "hello.txt", content: "hi\n" })).input,
|
||||
).toEqual({ filePath: "hello.txt", newString: "hi\n" });
|
||||
});
|
||||
|
||||
it("presents edit_file as a replacement in its path", () => {
|
||||
expect(
|
||||
transform(fxToolCall("edit_file", { path: "a.ts", old_string: "a", new_string: "b" })).input,
|
||||
).toEqual({ filePath: "a.ts", oldString: "a", newString: "b" });
|
||||
});
|
||||
|
||||
it("keeps the presentation when a later update reaches the same call", () => {
|
||||
const presented = transform(fxToolCall("write_file", { path: "hello.txt", content: "hi\n" }));
|
||||
expect(transform({ ...presented, status: "completed" }).input).toEqual(presented.input);
|
||||
});
|
||||
|
||||
it("leaves other tools alone", () => {
|
||||
const shell = fxToolCall("shell", { path: "/tmp", command: "ls" });
|
||||
expect(transform(shell)).toEqual(shell);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AcpToolCallSnapshot, AcpTransformer } from "@getpaseo/plugin/server/acp";
|
||||
|
||||
type ToolInput = AcpToolCallSnapshot["input"];
|
||||
|
||||
// fx names its file arguments for the model (`path`, `old_string`), so its file tools need the
|
||||
// argument names Paseo's edit rows read. The adapter reapplies this to each update of a call.
|
||||
export const fileToolTransformer: AcpTransformer = {
|
||||
toolCall(toolCall) {
|
||||
const input = toEditRowInput(toolCall.name, toolCall.input);
|
||||
return input ? { ...toolCall, input } : toolCall;
|
||||
},
|
||||
};
|
||||
|
||||
function toEditRowInput(tool: string | undefined, input: ToolInput): ToolInput | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
if (typeof input.path !== "string") return null;
|
||||
if (tool === "write_file") return { filePath: input.path, newString: input.content ?? null };
|
||||
if (tool === "edit_file") {
|
||||
return {
|
||||
filePath: input.path,
|
||||
oldString: input.old_string ?? null,
|
||||
newString: input.new_string ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { runAcpProvider } from "@getpaseo/plugin/server/acp";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
import { fileToolTransformer } from "./file-tools.js";
|
||||
|
||||
export function createFxProvider(): ProviderRegistration {
|
||||
return {
|
||||
requiresConfiguration: true,
|
||||
...runAcpProvider({
|
||||
id: "fx",
|
||||
label: "fx",
|
||||
description: "Vercel Labs' native coding agent through `fx acp`",
|
||||
icon: "icon.svg",
|
||||
command: ["fx", "acp"],
|
||||
transformers: [fileToolTransformer],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
|
||||
- [Dirac](https://dirac.run), open-source agent with hash-anchored parallel edits.
|
||||
- [Factory Droid](https://factory.ai/product/cli), Factory AI's coding agent.
|
||||
- [fast-agent](https://fast-agent.ai/acp/), multi-provider coding agent.
|
||||
- [fx](#fx), Vercel Labs' native coding agent.
|
||||
- [Gajae Code](https://gajae-code.com), subscription-based coding agent with plan-before-mutation workflows.
|
||||
- [Gemini CLI](https://geminicli.com), Google's official Gemini CLI.
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/), GitHub's AI pair programmer via ACP.
|
||||
@@ -102,6 +103,20 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
|
||||
|
||||
The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers).
|
||||
|
||||
### fx
|
||||
|
||||
[Install fx](https://fx.sh/docs/getting-started/installation) on your daemon host and
|
||||
[connect a provider](https://fx.sh/docs/getting-started/authentication), for example with
|
||||
`fx login`. In **Settings → Hosts → your host → Providers**, find **fx** in the catalog and
|
||||
choose **Add**. Paseo launches `fx acp` and uses your fx credentials and settings. Until fx has
|
||||
credentials, the provider reports the sign-in message from fx.
|
||||
|
||||
The model list comes from your active fx provider, so run `fx provider` to change the list a new
|
||||
agent starts from. Changing an agent's permission mode also changes fx's saved permission mode.
|
||||
See [fx's ACP guide](https://fx.sh/docs/using-fx/acp) for what fx supports. Use a
|
||||
[command override](/docs/custom-providers) if `fx` is outside your daemon's PATH; keep `acp` in
|
||||
the arguments.
|
||||
|
||||
### Hoplite
|
||||
|
||||
Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run
|
||||
|
||||
@@ -68,6 +68,7 @@ Paseo MCP 工具和引导功能不可用。你自己为 `agy` 配置的 MCP 服
|
||||
- [Dirac](https://dirac.run):开源 Agent,支持基于哈希锚点的并行编辑。
|
||||
- [Factory Droid](https://factory.ai/product/cli):Factory AI 的编程 Agent。
|
||||
- [fast-agent](https://fast-agent.ai/acp/):支持多家 Provider 的编程 Agent。
|
||||
- [fx](#fx):Vercel Labs 的原生编程 Agent。
|
||||
- [Gajae Code](https://gajae-code.com):订阅制编程 Agent,采用先规划再修改的工作流。
|
||||
- [Gemini CLI](https://geminicli.com):Google 官方的 Gemini CLI。
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/):通过 ACP 接入的 GitHub AI 结对编程助手。
|
||||
@@ -92,6 +93,12 @@ Paseo MCP 工具和引导功能不可用。你自己为 `agy` 配置的 MCP 服
|
||||
|
||||
应用内目录是权威的、固定版本的来源。这里未列出的 Agent 仍然可以手动添加,参见[自定义 Provider](/docs/custom-providers)。
|
||||
|
||||
### fx {#fx}
|
||||
|
||||
在 daemon 主机上[安装 fx](https://fx.sh/docs/getting-started/installation),并[连接一个模型提供方](https://fx.sh/docs/getting-started/authentication),例如运行 `fx login`。在 **设置 → Hosts → 你的主机 → Providers** 中,在目录里找到 **fx** 并选择 **Add**。Paseo 会启动 `fx acp`,并使用你的 fx 凭据和设置。在 fx 拿到凭据之前,该 provider 会显示 fx 给出的登录提示。
|
||||
|
||||
模型列表来自当前的 fx 提供方,运行 `fx provider` 可以更改新 Agent 使用的列表。更改 Agent 的权限模式也会更改 fx 保存的权限模式。fx 支持的功能见 [fx 的 ACP 指南](https://fx.sh/docs/using-fx/acp)。如果 `fx` 不在 daemon 的 PATH 中,请使用[命令覆盖](/docs/custom-providers),并在参数中保留 `acp`。
|
||||
|
||||
### Hoplite {#hoplite}
|
||||
|
||||
在 daemon 主机上安装 [Hoplite CLI](https://hoplite.sh/docs/cli) 并运行 `hoplite login`。在 **设置 → Hosts → 你的主机 → Providers** 中,在目录里找到 **Hoplite** 并选择 **添加**。Paseo 会通过通用 ACP 适配器启动 `hoplite acp`。
|
||||
|
||||
Reference in New Issue
Block a user