mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
feat(providers): make bundled Hoplite opt-in from the catalog
Keep the contributor ACP adapter and provisioning progress while activating it only when a provider config entry exists. Removing the entry removes membership across restarts; existing provider defaults remain unchanged. Present the Add provider catalog as a searchable grid and gate Hoplite activation on host support. Co-authored-by: Odin Glynn-Martin <odin.glynn@icloud.com>
This commit is contained in:
co-authored by
Odin Glynn-Martin
parent
665e15ed31
commit
95ea57746a
@@ -359,6 +359,8 @@ class AcpRuntime {
|
||||
private emit: (event: ProviderEvent) => void;
|
||||
private readonly messages = new Map<string, string>();
|
||||
private readonly toolCalls = new Map<string, AcpToolCallSnapshot>();
|
||||
// Running tool rows published by transformers; the turn's terminal closes any left open.
|
||||
private readonly vendorToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||
private readonly pendingCompactions = new Set<string>();
|
||||
private readonly permissions = new Map<
|
||||
string,
|
||||
@@ -595,10 +597,13 @@ class AcpRuntime {
|
||||
turnId,
|
||||
state: "started",
|
||||
});
|
||||
// The SDK dispatches notifications without awaiting them, so the prompt response can resolve
|
||||
// while earlier session updates are still queued. Apply them before the turn ends.
|
||||
const settled = this.call(
|
||||
this.connection.prompt({ sessionId: this.nativeSessionId, prompt }),
|
||||
).then(
|
||||
(response): void => {
|
||||
async (response): Promise<void> => {
|
||||
await this.drainNotifications();
|
||||
const state = response.stopReason === "cancelled" ? "canceled" : "completed";
|
||||
this.fallbackChunkIds.clear();
|
||||
this.terminalizeTransientItems(state);
|
||||
@@ -610,7 +615,8 @@ class AcpRuntime {
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
(error): void => {
|
||||
async (error): Promise<void> => {
|
||||
await this.drainNotifications();
|
||||
this.fallbackChunkIds.clear();
|
||||
this.terminalizeTransientItems("failed");
|
||||
this.emit({
|
||||
@@ -1027,9 +1033,17 @@ class AcpRuntime {
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
item: toolTimelineItem(completed),
|
||||
item: terminalToolItem(toolTimelineItem(snapshot), state),
|
||||
});
|
||||
}
|
||||
for (const item of this.vendorToolCalls.values()) {
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
item: terminalToolItem(item, state),
|
||||
});
|
||||
}
|
||||
this.vendorToolCalls.clear();
|
||||
for (const id of this.pendingCompactions) {
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
@@ -1076,6 +1090,10 @@ class AcpRuntime {
|
||||
if (this.configTransaction) this.stagedTransformerConfig = update.config;
|
||||
else this.emitConfigSnapshot(update.config);
|
||||
} else if (update.type === "timeline") {
|
||||
if (update.item.type === "tool_call") {
|
||||
if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item);
|
||||
else this.vendorToolCalls.delete(update.item.id);
|
||||
}
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
@@ -1383,7 +1401,9 @@ function configValues(
|
||||
return Object.fromEntries(options.map((option) => [option.id, option.currentValue]));
|
||||
}
|
||||
|
||||
function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem {
|
||||
type ToolCallTimelineItem = Extract<ProviderTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
|
||||
const input = jsonRecord(snapshot.input);
|
||||
const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true;
|
||||
const detail = isEdit
|
||||
@@ -1411,6 +1431,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem {
|
||||
return { ...base, status, error: null };
|
||||
}
|
||||
|
||||
// A row the turn left open ends with the turn. The wire contract requires a non-null error on a
|
||||
// failed tool call, so a canceled turn cancels its rows instead of failing them.
|
||||
function terminalToolItem(
|
||||
item: ToolCallTimelineItem,
|
||||
state: "completed" | "failed" | "canceled",
|
||||
): ToolCallTimelineItem {
|
||||
if (state === "failed") {
|
||||
return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } };
|
||||
}
|
||||
return { ...item, status: state, error: null };
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown) {
|
||||
return z.json().parse(value);
|
||||
}
|
||||
|
||||
@@ -619,6 +619,146 @@ lines.on("line", (line) => {
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["end_turn", "completed", "completed"],
|
||||
["cancelled", "canceled", "canceled"],
|
||||
] as const)(
|
||||
"closes a transformer's running tool row when the turn ends with %s",
|
||||
async (stopReason, turnState, rowStatus) => {
|
||||
const harness = connectorHarness({
|
||||
handleMessage(instance, message) {
|
||||
if (!("method" in message) || !("id" in message)) return false;
|
||||
if (message.method !== "session/prompt") return false;
|
||||
instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" });
|
||||
instance.respond(message as AcpRequestMessage, { stopReason });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "vendor-row-acp",
|
||||
label: "Vendor row ACP",
|
||||
connector: harness.connector,
|
||||
transformers: [
|
||||
{
|
||||
notification(notification) {
|
||||
if (notification.method !== "vendor/phase") return null;
|
||||
return {
|
||||
type: "timeline",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
id: "phase-1",
|
||||
callId: "phase-1",
|
||||
name: "Vendor phase",
|
||||
detail: { type: "plain_text", label: "Preparing" },
|
||||
status: "running",
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === turnState,
|
||||
);
|
||||
|
||||
const rows = events.flatMap((event) =>
|
||||
event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [],
|
||||
);
|
||||
expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([
|
||||
"running",
|
||||
rowStatus,
|
||||
]);
|
||||
const closed = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "timeline.item" &&
|
||||
event.item.id === "phase-1" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === rowStatus,
|
||||
);
|
||||
const terminal = events.findIndex(
|
||||
(event) => event.type === "session.turn" && event.state === turnState,
|
||||
);
|
||||
expect(closed).toBeLessThan(terminal);
|
||||
// The wire contract only accepts a failed tool call with a non-null error.
|
||||
expect(rows.at(-1)).toMatchObject({ error: null });
|
||||
await connection.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => {
|
||||
const harness = connectorHarness({
|
||||
handleMessage(instance, message) {
|
||||
if (!("method" in message) || !("id" in message)) return false;
|
||||
if (message.method !== "session/prompt") return false;
|
||||
instance.notify("session/update", {
|
||||
sessionId: "connector-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "Run tests",
|
||||
kind: "execute",
|
||||
status: "in_progress",
|
||||
},
|
||||
});
|
||||
instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "cancel-tool-acp",
|
||||
label: "Cancel tool ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === "canceled",
|
||||
);
|
||||
|
||||
const rows = events.flatMap((event) =>
|
||||
event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [],
|
||||
);
|
||||
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null });
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("serializes configuration mutations", async () => {
|
||||
let releaseMutation: (() => void) | null = null;
|
||||
const configOptions = [
|
||||
|
||||
@@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [
|
||||
export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number];
|
||||
|
||||
export interface ProviderRegistration {
|
||||
/** Activate only while agents.providers contains this provider ID, independently of enabled. */
|
||||
requiresConfiguration?: boolean;
|
||||
/** Equal keys share discovery within this provider. Include effective configuration and execution environment. */
|
||||
getCatalogCacheKey?(options: ProviderCatalogOptions): Promise<string | undefined>;
|
||||
/** Default executable and arguments; the daemon resolves overrides before status/connect. */
|
||||
|
||||
Reference in New Issue
Block a user