mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Let plugins provide coding agents (#4314)
Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation. Closes #3547 Closes #3556
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# ACP provider transformer example
|
||||
|
||||
This server plugin registers an ACP command through `runAcpProvider()`. The helper owns ACP process
|
||||
and SDK details behind Paseo's callback-based provider connection.
|
||||
|
||||
`server/vendor-edit.ts` shows the intended extension seam: a focused transformer converts one
|
||||
vendor tool payload into Paseo's ordinary file-edit shape. It does not translate the whole event
|
||||
stream.
|
||||
|
||||
Replace the placeholder `example-acp --stdio` command with an installed ACP agent before enabling
|
||||
the plugin.
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<path d="m8 3-4 4 4 4m8 2 4 4-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,17 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { runAcpProvider } from "@getpaseo/plugin/acp";
|
||||
import { vendorEditTransformer } from "./server/vendor-edit.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(
|
||||
runAcpProvider({
|
||||
id: "example-acp",
|
||||
label: "Example ACP",
|
||||
description: "An ACP command adapted to Paseo's provider boundary",
|
||||
icon: "icon.svg",
|
||||
command: ["example-acp", "--stdio"],
|
||||
transformers: [vendorEditTransformer],
|
||||
}),
|
||||
);
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "provider-acp-transformer-example",
|
||||
"name": "ACP provider transformer example",
|
||||
"version": "1.0.0",
|
||||
"description": "Wraps an ACP command and normalizes one vendor file-edit shape."
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { vendorEditTransformer } from "./vendor-edit.js";
|
||||
|
||||
describe("vendorEditTransformer", () => {
|
||||
it("normalizes only the vendor edit payload", () => {
|
||||
const transformed = vendorEditTransformer.toolCall?.(
|
||||
{
|
||||
id: "edit-1",
|
||||
name: "vendor_file_edit",
|
||||
title: "Edit file",
|
||||
status: "completed",
|
||||
input: { path: "src/app.ts", before: "old", after: "new" },
|
||||
output: null,
|
||||
locations: [],
|
||||
},
|
||||
{ sessionId: "session-1" },
|
||||
);
|
||||
|
||||
expect(transformed).toMatchObject({
|
||||
kind: "edit",
|
||||
input: { filePath: "src/app.ts", oldString: "old", newString: "new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a vendor edit with a malformed payload", () => {
|
||||
const toolCall = {
|
||||
id: "edit-1",
|
||||
name: "vendor_file_edit",
|
||||
title: "Edit file",
|
||||
status: "completed" as const,
|
||||
input: { path: "src/app.ts", before: "old" },
|
||||
output: null,
|
||||
locations: [],
|
||||
};
|
||||
|
||||
const transformed = vendorEditTransformer.toolCall?.(toolCall, {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(transformed).toBe(toolCall);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AcpTransformer } from "@getpaseo/plugin/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
const vendorEditInputSchema = z.object({
|
||||
path: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
});
|
||||
|
||||
export const vendorEditTransformer: AcpTransformer = {
|
||||
toolCall(toolCall) {
|
||||
if (toolCall.name !== "vendor_file_edit") return toolCall;
|
||||
const input = vendorEditInputSchema.safeParse(toolCall.input);
|
||||
if (!input.success) return toolCall;
|
||||
return {
|
||||
...toolCall,
|
||||
kind: "edit",
|
||||
input: {
|
||||
filePath: input.data.path,
|
||||
oldString: input.data.before,
|
||||
newString: input.data.after,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# Direct provider example
|
||||
|
||||
This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation,
|
||||
catalog and session listing, session creation, prompt and command disposition, settings,
|
||||
persistence and replay, archive actions, and an ordinary child session.
|
||||
|
||||
The provider emits versioned `provider-result` timeline items for its root and child sessions.
|
||||
`index.client.tsx` registers the Zod schema and React Native renderer for those items. The server
|
||||
and client share only the item kind and schema under `shared/`.
|
||||
|
||||
The provider receives Paseo host tools through `config.mcpServers`; it does not receive a second
|
||||
callback tool API.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginTimelineItemProps } from "@getpaseo/plugin";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { z } from "zod";
|
||||
import { providerResultSchema } from "../shared/provider-result";
|
||||
|
||||
type ProviderResultData = z.output<typeof providerResultSchema>;
|
||||
|
||||
export function ProviderResult({ item, theme }: PluginTimelineItemProps<ProviderResultData>) {
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
card: {
|
||||
gap: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
label: { color: theme.colors.accent, fontWeight: "600" as const },
|
||||
detail: { color: theme.colors.foreground },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>{item.data.label}</Text>
|
||||
<Text style={styles.detail}>{item.data.detail}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 5h14v14H5z" />
|
||||
<path d="m9 9 6 6m0-6-6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1,13 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { ProviderResult } from "./client/provider-result";
|
||||
import { providerResultKind, providerResultSchema } from "./shared/provider-result";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addTimelineRenderer({
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
schema: providerResultSchema,
|
||||
Component: ProviderResult,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createDirectExampleProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createDirectExampleProvider());
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "provider-direct-example"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ProviderEvent } from "@getpaseo/plugin/provider";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDirectExampleProvider } from "./provider.js";
|
||||
|
||||
describe("direct provider example", () => {
|
||||
it("fails steering when no turn is active without creating a turn", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.command", "prompt.message", "prompt.steer"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-1",
|
||||
sessionId: "session-1",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false },
|
||||
history: "skip",
|
||||
});
|
||||
const turnEventsBefore = events.filter((event) => event.type === "session.turn").length;
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "steer-1",
|
||||
delivery: "steer",
|
||||
input: { type: "command", name: "reset", arguments: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "session.prompt_result",
|
||||
sessionId: "session-1",
|
||||
clientMessageId: "steer-1",
|
||||
result: { type: "failed", error: { message: "There is no active turn to steer" } },
|
||||
});
|
||||
expect(events.filter((event) => event.type === "session.turn")).toHaveLength(turnEventsBefore);
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "session.notice" }));
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: [
|
||||
"prompt.message",
|
||||
"prompt.command",
|
||||
"session.configure",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"timeline.plugin",
|
||||
],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-complete",
|
||||
sessionId: "session-complete",
|
||||
config: {
|
||||
cwd: "/repo",
|
||||
env: { EXAMPLE: "1" },
|
||||
systemPrompt: "Be useful",
|
||||
mcpServers: {},
|
||||
model: "example-1",
|
||||
mode: "build",
|
||||
settings: { concise: true, voice: "calm" },
|
||||
providerOptions: { privateValue: 1 },
|
||||
persist: true,
|
||||
},
|
||||
history: "skip",
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.config",
|
||||
config: expect.objectContaining({
|
||||
settings: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "concise", value: true }),
|
||||
expect.objectContaining({ id: "voice", value: "calm" }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "session.commands" }));
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-complete",
|
||||
sessionId: "session-complete",
|
||||
changes: { settings: { voice: "direct" } },
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-complete",
|
||||
prompt: {
|
||||
clientMessageId: "command-1",
|
||||
delivery: "auto",
|
||||
input: { type: "command", name: "reset", arguments: "" },
|
||||
},
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-complete",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.prompt_result",
|
||||
clientMessageId: "command-1",
|
||||
result: { type: "completed" },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({ type: "user_message", clientMessageId: "message-1" }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
sessionId: "session-complete",
|
||||
item: expect.objectContaining({ type: "plugin", kind: "provider-result" }),
|
||||
}),
|
||||
);
|
||||
const child = events.find(
|
||||
(event): event is Extract<ProviderEvent, { type: "session.opened" }> =>
|
||||
event.type === "session.opened" && event.parentSessionId === "session-complete",
|
||||
);
|
||||
expect(child?.sessionId).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
sessionId: child?.sessionId,
|
||||
item: expect.objectContaining({ type: "plugin", kind: "provider-result" }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "session.persistence" }));
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("rejects calls after the example connection closes", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
|
||||
await expect(
|
||||
connection.send({ type: "catalog", requestId: "catalog-after-close" }),
|
||||
).rejects.toThrow("Provider connection is closed");
|
||||
await expect(
|
||||
connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-after-close",
|
||||
sessionId: "closed-session",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false },
|
||||
history: "skip",
|
||||
}),
|
||||
).rejects.toThrow("Provider connection is closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
negotiateProviderCapabilities,
|
||||
requireProviderCapabilities,
|
||||
type ProviderConfigState,
|
||||
type ProviderConnection,
|
||||
type ProviderEvent,
|
||||
type ProviderInput,
|
||||
type ProviderPersistence,
|
||||
type ProviderRegistration,
|
||||
type ProviderSessionConfig,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { providerResultKind } from "../shared/provider-result.js";
|
||||
|
||||
const CAPABILITIES = [
|
||||
"prompt.message",
|
||||
"prompt.command",
|
||||
"prompt.steer",
|
||||
"session.archive",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"session.unarchive",
|
||||
"timeline.plugin",
|
||||
] as const;
|
||||
|
||||
interface ExampleSession {
|
||||
config: ProviderSessionConfig;
|
||||
persistence: ProviderPersistence;
|
||||
turn: number;
|
||||
activeTurnId: string | null;
|
||||
}
|
||||
|
||||
export function createDirectExampleProvider(): ProviderRegistration {
|
||||
return {
|
||||
id: "direct-example",
|
||||
label: "Direct provider example",
|
||||
description: "A complete provider implemented directly against Paseo's provider boundary",
|
||||
icon: "icon.svg",
|
||||
async connect(request) {
|
||||
if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
|
||||
return createConnection(negotiateProviderCapabilities(request.capabilities, CAPABILITIES));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConnection(capabilities: readonly string[]): ProviderConnection {
|
||||
const listeners = new Set<(event: ProviderEvent) => void>();
|
||||
const sessions = new Map<string, ExampleSession>();
|
||||
const archived = new Set<string>();
|
||||
let closed = false;
|
||||
const emit = (event: ProviderEvent) => {
|
||||
if (closed) return;
|
||||
for (const listener of listeners) listener(event);
|
||||
};
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
capabilities,
|
||||
async send(input) {
|
||||
if (closed) throw new Error("Provider connection is closed");
|
||||
validateAdmission(input, { sessions, capabilities });
|
||||
queueMicrotask(() => {
|
||||
if (!closed) dispatch(input, { sessions, archived, emit, capabilities });
|
||||
});
|
||||
},
|
||||
onEvent(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sessions.clear();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateAdmission(
|
||||
input: ProviderInput,
|
||||
state: Pick<ExampleState, "sessions" | "capabilities">,
|
||||
): void {
|
||||
if (input.type === "session.open") {
|
||||
if (state.sessions.has(input.sessionId)) {
|
||||
throw new Error(`Session already exists: ${input.sessionId}`);
|
||||
}
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
return;
|
||||
}
|
||||
if (!("sessionId" in input)) {
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
return;
|
||||
}
|
||||
const session = state.sessions.get(input.sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${input.sessionId}`);
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
}
|
||||
|
||||
interface ExampleState {
|
||||
sessions: Map<string, ExampleSession>;
|
||||
archived: Set<string>;
|
||||
emit(event: ProviderEvent): void;
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
|
||||
function dispatch(input: ProviderInput, state: ExampleState): void {
|
||||
switch (input.type) {
|
||||
case "catalog":
|
||||
state.emit({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: {
|
||||
models: [{ id: "example-1", label: "Example 1" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
defaultModel: "example-1",
|
||||
defaultMode: "build",
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "sessions":
|
||||
state.emit({
|
||||
type: "sessions",
|
||||
requestId: input.requestId,
|
||||
sessions: [...state.sessions.values()].map((session) => ({
|
||||
persistence: session.persistence,
|
||||
cwd: session.config.cwd,
|
||||
title: session.config.title,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
case "session.open":
|
||||
openSession(input, state);
|
||||
return;
|
||||
case "session.prompt":
|
||||
promptSession(input, state);
|
||||
return;
|
||||
case "session.configure":
|
||||
configureSession(input, state);
|
||||
return;
|
||||
case "session.interrupt":
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.permission":
|
||||
state.emit({
|
||||
type: "session.permission_resolved",
|
||||
sessionId: input.sessionId,
|
||||
permissionId: input.permissionId,
|
||||
});
|
||||
return;
|
||||
case "session.revert":
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.archive":
|
||||
state.archived.add(persistenceKey(input.persistence));
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.unarchive":
|
||||
state.archived.delete(persistenceKey(input.persistence));
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.close":
|
||||
state.sessions.delete(input.sessionId);
|
||||
state.emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
}
|
||||
}
|
||||
|
||||
function openSession(
|
||||
input: Extract<ProviderInput, { type: "session.open" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const persistence = input.persistence ?? {
|
||||
version: 1,
|
||||
data: { nativeSessionId: `example:${input.sessionId}` },
|
||||
};
|
||||
const session: ExampleSession = {
|
||||
config: input.config,
|
||||
persistence,
|
||||
turn: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
state.sessions.set(input.sessionId, session);
|
||||
state.emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: state.capabilities,
|
||||
restoration: "core",
|
||||
persistence,
|
||||
title: input.config.title,
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
|
||||
state.emit({
|
||||
type: "session.commands",
|
||||
sessionId: input.sessionId,
|
||||
commands: [{ name: "reset", description: "Reset the example provider state" }],
|
||||
});
|
||||
if (input.history === "replay" && input.persistence) {
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: "replayed-1", text: "Restored from persistence" },
|
||||
});
|
||||
}
|
||||
state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
}
|
||||
|
||||
function promptSession(
|
||||
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const session = requireSession(state, input.sessionId);
|
||||
if (input.prompt.delivery === "steer") {
|
||||
if (!session.activeTurnId) {
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "failed", error: { message: "There is no active turn to steer" } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "steer", turnId: session.activeTurnId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.prompt.input.type === "command") {
|
||||
if (input.prompt.input.name !== "reset") {
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "failed", error: { message: "Unknown command" } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.turn = 0;
|
||||
state.emit({
|
||||
type: "session.notice",
|
||||
sessionId: input.sessionId,
|
||||
notice: { id: "reset", severity: "info", title: "Example state reset" },
|
||||
});
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "completed" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
session.turn += 1;
|
||||
const turnId = `turn-${session.turn}`;
|
||||
session.activeTurnId = turnId;
|
||||
const text = input.prompt.input.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user-${session.turn}`,
|
||||
text,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
},
|
||||
});
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: `assistant-${session.turn}`, text: `Echo: ${text}` },
|
||||
});
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: `provider-result-${session.turn}`,
|
||||
pluginId: "provider-direct-example",
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
data: { label: "Provider result", detail: `Echoed turn ${session.turn}` },
|
||||
},
|
||||
});
|
||||
publishChild(input.sessionId, session.turn, state);
|
||||
session.persistence = {
|
||||
version: 1,
|
||||
data: { nativeSessionId: `example:${input.sessionId}`, turns: session.turn },
|
||||
};
|
||||
state.emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: session.persistence,
|
||||
});
|
||||
state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
|
||||
session.activeTurnId = null;
|
||||
}
|
||||
|
||||
function publishChild(parentSessionId: string, turn: number, state: ExampleState): void {
|
||||
const sessionId = randomUUID();
|
||||
state.emit({
|
||||
type: "session.opened",
|
||||
sessionId,
|
||||
parentSessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
title: "Example child",
|
||||
cwd: requireSession(state, parentSessionId).config.cwd,
|
||||
});
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: `child-result-${turn}`,
|
||||
pluginId: "provider-direct-example",
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
data: { label: "Child result", detail: "Completed" },
|
||||
},
|
||||
});
|
||||
state.emit({ type: "session.ready", sessionId });
|
||||
}
|
||||
|
||||
function configureSession(
|
||||
input: Extract<ProviderInput, { type: "session.configure" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const session = requireSession(state, input.sessionId);
|
||||
const changes = input.changes;
|
||||
session.config = {
|
||||
...session.config,
|
||||
model: changes.model === null ? undefined : (changes.model ?? session.config.model),
|
||||
mode: changes.mode === null ? undefined : (changes.mode ?? session.config.mode),
|
||||
thinkingOption:
|
||||
changes.thinkingOption === null
|
||||
? undefined
|
||||
: (changes.thinkingOption ?? session.config.thinkingOption),
|
||||
settings: changes.settings
|
||||
? { ...session.config.settings, ...changes.settings }
|
||||
: session.config.settings,
|
||||
};
|
||||
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
}
|
||||
|
||||
function configState(session: ExampleSession): ProviderConfigState {
|
||||
return {
|
||||
model: session.config.model ?? "example-1",
|
||||
mode: session.config.mode ?? "build",
|
||||
thinkingOption: session.config.thinkingOption,
|
||||
models: [{ id: "example-1", label: "Example 1" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "concise",
|
||||
label: "Concise responses",
|
||||
value: session.config.settings.concise === true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value:
|
||||
typeof session.config.settings.voice === "string" ? session.config.settings.voice : null,
|
||||
options: [
|
||||
{ label: "Calm", value: "calm" },
|
||||
{ label: "Direct", value: "direct" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function requireSession(state: ExampleState, sessionId: string): ExampleSession {
|
||||
const session = state.sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${sessionId}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function persistenceKey(persistence: ProviderPersistence): string {
|
||||
return JSON.stringify(persistence);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const providerResultKind = "provider-result";
|
||||
|
||||
export const providerResultSchema = z.object({
|
||||
label: z.string(),
|
||||
detail: z.string(),
|
||||
});
|
||||
Reference in New Issue
Block a user