mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
fix(server): keep plugin startup settings, opening reconciliation and reset defaults across rewind
A plugin that cannot be configured takes its settings in the open request as before; only a configurable plugin opens without overrides so its reported values can serve as reset defaults. Option changes a plugin makes while its session opens are held and delivered to the manager when it subscribes, so the stored option follows the committed value. The reset defaults belong to the logical session and survive the session being reopened after a rewind.
This commit is contained in:
@@ -898,17 +898,22 @@ function voiceConfig(voice: string) {
|
||||
* A plugin whose `voice` setting defaults to "direct". With `acceptsVoice: false` it completes a
|
||||
* configure request but keeps its voice.
|
||||
*/
|
||||
function startVoicePlugin(input: { acceptsVoice: boolean }) {
|
||||
function startVoicePlugin(input: { acceptsVoice: boolean; configurable?: boolean }) {
|
||||
const capabilities = [
|
||||
...CAPABILITIES.filter((name) => input.configurable !== false || name !== "session.configure"),
|
||||
"session.revert.conversation",
|
||||
];
|
||||
const harness = createProviderHarness({
|
||||
capabilities,
|
||||
async handleInput(request, emit) {
|
||||
if (request.type === "session.open") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
capabilities: CAPABILITIES,
|
||||
capabilities,
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
persistence: request.persistence ?? { version: 1, data: { token: "root" } },
|
||||
cwd: request.config.cwd,
|
||||
});
|
||||
emit({
|
||||
@@ -919,6 +924,31 @@ function startVoicePlugin(input: { acceptsVoice: boolean }) {
|
||||
emit({ type: "session.ready", requestId: request.requestId, sessionId: request.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (request.type === "session.prompt") {
|
||||
if (request.prompt.input.type !== "message") throw new Error("Expected a message");
|
||||
const text = request.prompt.input.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
const turnId = `turn-${text}`;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: request.sessionId,
|
||||
clientMessageId: request.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: request.sessionId, turnId, state: "started" });
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: request.sessionId,
|
||||
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: request.sessionId, turnId, state: "completed" });
|
||||
return true;
|
||||
}
|
||||
if (request.type === "session.revert") {
|
||||
emit({ type: "request.completed", requestId: request.requestId });
|
||||
return true;
|
||||
}
|
||||
if (request.type !== "session.configure") return false;
|
||||
const requested = request.changes.settings?.voice;
|
||||
emit({
|
||||
@@ -1732,6 +1762,66 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a setting passed to a plugin that cannot be configured goes in the open request", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({
|
||||
acceptsVoice: true,
|
||||
configurable: false,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open");
|
||||
expect(opened?.type === "session.open" && opened.config.settings).toEqual({ voice: "soft" });
|
||||
expect(configuredSettings(harness)).toEqual([]);
|
||||
expect(toAgentPayload(manager.getAgent(agent.id)!).options).toEqual({ voice: "soft" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selection the plugin settles on while the agent is created replaces the requested option", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({ voice: "direct" });
|
||||
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("removing an option after a conversation rewind returns the setting to the plugin's default", async () => {
|
||||
const { harness, registry } = startVoicePlugin({ acceptsVoice: true });
|
||||
try {
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
providerOptions: { voice: "soft" },
|
||||
});
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
|
||||
await session.applyOptions!({});
|
||||
|
||||
expect(configuredSettings(harness)).toEqual([{ voice: "soft" }, { voice: "direct" }]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selection the plugin keeps on its previous value replaces the requested option", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
|
||||
try {
|
||||
|
||||
@@ -195,6 +195,11 @@ class ProviderRuntime {
|
||||
return this.connection?.capabilities ?? [];
|
||||
}
|
||||
|
||||
/** The capabilities of the connection, connecting first if it is not open yet. */
|
||||
async connectedCapabilities(): Promise<readonly string[]> {
|
||||
return (await this.getConnection()).capabilities;
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
@@ -1097,13 +1102,13 @@ class PluginAgentClient implements AgentClient {
|
||||
}): Promise<PluginAgentSession> {
|
||||
const sessionId = randomUUID();
|
||||
// The plugin reports its own setting values once open, and those are what a later option
|
||||
// removal returns to. So the session opens without setting overrides and applies them after.
|
||||
// removal returns to. So a plugin that can be configured opens without setting overrides and
|
||||
// has them applied after. One that cannot takes them in the open request.
|
||||
const configurable = (await this.runtime.connectedCapabilities()).includes("session.configure");
|
||||
const openConfig = mapSessionConfig(input.config, input.launchContext, input.persist);
|
||||
const bridge = await this.runtime.openSession({
|
||||
sessionId,
|
||||
config: {
|
||||
...mapSessionConfig(input.config, input.launchContext, input.persist),
|
||||
settings: {},
|
||||
},
|
||||
config: configurable ? { ...openConfig, settings: {} } : openConfig,
|
||||
persistence: input.persistence,
|
||||
history: input.history,
|
||||
});
|
||||
@@ -1143,7 +1148,7 @@ class PluginAgentClient implements AgentClient {
|
||||
);
|
||||
bind(bridge);
|
||||
try {
|
||||
await session.applyOptions(input.config.providerOptions ?? {});
|
||||
if (configurable) await session.applyOptions(input.config.providerOptions ?? {});
|
||||
} catch (error) {
|
||||
await session.close();
|
||||
throw error;
|
||||
@@ -1224,8 +1229,13 @@ class PluginAgentSession implements AgentSession {
|
||||
closed: boolean;
|
||||
} | null = null;
|
||||
private replaying: Promise<void> | null = null;
|
||||
/** Each setting's value before Paseo first changed it, for an option that is later removed. */
|
||||
/**
|
||||
* Each setting's value before Paseo first changed it, for an option that is later removed.
|
||||
* It belongs to the logical session, so it survives the session being reopened after a rewind.
|
||||
*/
|
||||
private startingSettings = new Map<string, JsonValue>();
|
||||
/** Option changes the plugin made before anything listened, delivered to the first subscriber. */
|
||||
private optionsChangedBeforeSubscribe: Record<string, JsonValue> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
@@ -1345,6 +1355,11 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
if (this.optionsChangedBeforeSubscribe) {
|
||||
const patch = this.optionsChangedBeforeSubscribe;
|
||||
this.optionsChangedBeforeSubscribe = null;
|
||||
callback({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
@@ -1541,7 +1556,13 @@ class PluginAgentSession implements AgentSession {
|
||||
// A setting that is not what the agent holds is an option change, so the stored option
|
||||
// follows what the plugin reports.
|
||||
const patch = this.changedSettings(event.config.settings);
|
||||
if (patch) this.emit({ type: "options_changed", provider: this.provider, patch });
|
||||
if (!patch) return;
|
||||
// The plugin can settle on other values while the session opens, before the manager listens.
|
||||
if (this.listeners.size === 0) {
|
||||
this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch };
|
||||
return;
|
||||
}
|
||||
this.emit({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1826,7 +1847,6 @@ class PluginAgentSession implements AgentSession {
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
this.startingSettings.clear();
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
|
||||
this.onBind(bridge);
|
||||
|
||||
Reference in New Issue
Block a user