mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e63fbb5e | ||
|
|
51e844b540 | ||
|
|
367a9d908b | ||
|
|
eef309ec06 | ||
|
|
d75f60ebcc | ||
|
|
6a4c05ce82 |
@@ -358,6 +358,125 @@ register(${JSON.stringify(guardUrl)});`;
|
|||||||
await adapters.shutdown();
|
await adapters.shutdown();
|
||||||
await runtime.stopAll();
|
await runtime.stopAll();
|
||||||
});
|
});
|
||||||
|
// The muse provider runs against a fake MSP host (injected through the
|
||||||
|
// provider's command override), but everything above it is real: the plugin
|
||||||
|
// subprocess, PluginRuntime's capability enforcement, and the
|
||||||
|
// PluginAgentClientRegistry path an agent actually uses. On a muse build
|
||||||
|
// that does not negotiate `session.subsession`, the daemon fails the whole
|
||||||
|
// provider connection when the child is projected, so both tests fail
|
||||||
|
// before the fix.
|
||||||
|
async function startMuseClient(extraEnv: Record<string, string>) {
|
||||||
|
const fakeMuse = fileURLToPath(
|
||||||
|
new URL("../../../../../plugins/muse-provider/test/fake-muse.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
const directory = fileURLToPath(
|
||||||
|
new URL("../../../../../plugins/muse-provider/", import.meta.url),
|
||||||
|
);
|
||||||
|
const runtime = createTestRuntime({}, undefined, "0.11.0");
|
||||||
|
await runtime.startPlugin("muse-provider", directory);
|
||||||
|
const [metadata] = runtime.getProviderRegistrations("muse-provider");
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
const adapters = new PluginAgentClientRegistry(pino({ level: "silent" }));
|
||||||
|
adapters.replace([
|
||||||
|
{
|
||||||
|
...adaptRuntimeProvider(runtime, "muse-provider", metadata!),
|
||||||
|
command: metadata!.command,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const client = adapters.definitions()[metadata!.id]!.createClient(pino({ level: "silent" }), {
|
||||||
|
command: { mode: "replace", argv: [process.execPath, fakeMuse] },
|
||||||
|
env: { MUSE_TEST_SCENARIO: "subagent", MUSE_TEST_CHILD: "tool", ...extraEnv },
|
||||||
|
});
|
||||||
|
return { runtime, adapters, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSettledTurn(events: readonly AgentStreamEvent[]): boolean {
|
||||||
|
return events.some((event) => event.type === "turn_completed" || event.type === "turn_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserting on the settled turn (rather than polling only for completion)
|
||||||
|
// puts the provider's failure — e.g. "Provider parent session did not
|
||||||
|
// negotiate session.subsession" — in the test output.
|
||||||
|
async function expectTurnCompleted(events: readonly AgentStreamEvent[]): Promise<void> {
|
||||||
|
await expect.poll(() => hasSettledTurn(events)).toBe(true);
|
||||||
|
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
|
||||||
|
expect(hasCompletedAgentTurn(events)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSubagentTimeline(events: readonly AgentStreamEvent[]): boolean {
|
||||||
|
return events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "timeline" &&
|
||||||
|
event.event.item.type === "assistant_message" &&
|
||||||
|
event.event.item.text.includes("SUBAGENT_PHASE0_OK"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("projects a muse subagent through PluginRuntime without failing the connection", async () => {
|
||||||
|
const { runtime, adapters, client } = await startMuseClient({});
|
||||||
|
try {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
const session = await client.createSession({ provider: "muse", cwd: process.cwd() });
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
await session.startTurn("dispatch a subagent", { clientMessageId: "muse-live" });
|
||||||
|
await expectTurnCompleted(events);
|
||||||
|
expect(hasSubagentTimeline(events)).toBe(true);
|
||||||
|
// The connection survives: a fresh session on the same provider opens
|
||||||
|
// and completes a turn instead of hitting a dead cached connection.
|
||||||
|
const second = await client.createSession({ provider: "muse", cwd: process.cwd() });
|
||||||
|
const secondEvents: AgentStreamEvent[] = [];
|
||||||
|
second.subscribe((event) => secondEvents.push(event));
|
||||||
|
await second.startTurn("again", { clientMessageId: "muse-live-2" });
|
||||||
|
await expectTurnCompleted(secondEvents);
|
||||||
|
await second.close();
|
||||||
|
await session.close();
|
||||||
|
} finally {
|
||||||
|
await adapters.shutdown();
|
||||||
|
await runtime.stopAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a muse subagent from history on resume without failing the connection", async () => {
|
||||||
|
const historyDirectory = await mkdtemp(path.join(tmpdir(), "paseo-muse-history-"));
|
||||||
|
temporaryDirectories.push(historyDirectory);
|
||||||
|
const { runtime, adapters, client } = await startMuseClient({
|
||||||
|
MUSE_TEST_HISTORY: path.join(historyDirectory, "native-history.ndjson"),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const first = await client.createSession({ provider: "muse", cwd: process.cwd() });
|
||||||
|
const persistence = first.describePersistence();
|
||||||
|
expect(persistence).not.toBeNull();
|
||||||
|
await first.close();
|
||||||
|
|
||||||
|
// Seed old history without running a live subagent first, so this test
|
||||||
|
// reaches resume even when live child projection is broken.
|
||||||
|
await writeFile(
|
||||||
|
path.join(historyDirectory, "native-history.ndjson"),
|
||||||
|
JSON.stringify({
|
||||||
|
itemId: "historical-subagent",
|
||||||
|
revision: 1,
|
||||||
|
kind: "toolCall",
|
||||||
|
status: "completed",
|
||||||
|
tool: "subagent_spawn",
|
||||||
|
childSessionId: "fixture-child",
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
const resumed = await client.resumeSession(persistence!, { cwd: process.cwd() });
|
||||||
|
const resumedEvents: AgentStreamEvent[] = [];
|
||||||
|
resumed.subscribe((event) => resumedEvents.push(event));
|
||||||
|
// History replayed during resume is exposed through streamHistory.
|
||||||
|
const replayed: AgentStreamEvent[] = [];
|
||||||
|
for await (const event of resumed.streamHistory()) replayed.push(event);
|
||||||
|
expect(hasSubagentTimeline(replayed)).toBe(true);
|
||||||
|
await resumed.startTurn("continue", { clientMessageId: "muse-resumed" });
|
||||||
|
await expectTurnCompleted(resumedEvents);
|
||||||
|
await resumed.close();
|
||||||
|
} finally {
|
||||||
|
await adapters.shutdown();
|
||||||
|
await runtime.stopAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("runs a provider connection through the real plugin subprocess boundary", async () => {
|
it("runs a provider connection through the real plugin subprocess boundary", async () => {
|
||||||
const directory = await createPlugin(
|
const directory = await createPlugin(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class Children {
|
|||||||
private readonly parentId: string,
|
private readonly parentId: string,
|
||||||
private readonly cwd: string,
|
private readonly cwd: string,
|
||||||
private readonly emit: (event: ProviderEvent) => void,
|
private readonly emit: (event: ProviderEvent) => void,
|
||||||
|
private readonly subsessions: boolean,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async update(item: WireItem): Promise<void> {
|
async update(item: WireItem): Promise<void> {
|
||||||
@@ -34,6 +35,10 @@ export class Children {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
// Emitting a child session.opened with parentSessionId is only valid when the
|
||||||
|
// parent session negotiated the session.subsession capability; the daemon
|
||||||
|
// fails the provider connection otherwise.
|
||||||
|
if (!this.subsessions) return;
|
||||||
const id = item.childSessionId;
|
const id = item.childSessionId;
|
||||||
if (!this.children.has(id)) {
|
if (!this.children.has(id)) {
|
||||||
const { session } = await this.host.request(
|
const { session } = await this.host.request(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const capabilities = [
|
|||||||
"session.persistence",
|
"session.persistence",
|
||||||
"permission",
|
"permission",
|
||||||
"session.list",
|
"session.list",
|
||||||
|
"session.subsession",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function createMuseProvider(usage: Usage): ProviderRegistration {
|
export function createMuseProvider(usage: Usage): ProviderRegistration {
|
||||||
|
|||||||
@@ -142,7 +142,13 @@ export class Session {
|
|||||||
const nativeId = response.session.sessionId;
|
const nativeId = response.session.sessionId;
|
||||||
this.liveState = {
|
this.liveState = {
|
||||||
nativeId,
|
nativeId,
|
||||||
children: new Children(this.host, this.options.id, this.config.cwd, this.options.emit),
|
children: new Children(
|
||||||
|
this.host,
|
||||||
|
this.options.id,
|
||||||
|
this.config.cwd,
|
||||||
|
this.options.emit,
|
||||||
|
this.options.capabilities.includes("session.subsession"),
|
||||||
|
),
|
||||||
timeline: new Timeline(
|
timeline: new Timeline(
|
||||||
this.host,
|
this.host,
|
||||||
nativeId,
|
nativeId,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async function harness(
|
|||||||
scenario = "text-reasoning",
|
scenario = "text-reasoning",
|
||||||
env: Record<string, string> = {},
|
env: Record<string, string> = {},
|
||||||
providerOptions?: Record<string, unknown>,
|
providerOptions?: Record<string, unknown>,
|
||||||
|
connectCapabilities: readonly string[] = PROVIDER_CAPABILITIES,
|
||||||
) {
|
) {
|
||||||
const root = await mkdtemp(path.join(os.tmpdir(), "muse-provider-test-"));
|
const root = await mkdtemp(path.join(os.tmpdir(), "muse-provider-test-"));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
@@ -62,7 +63,7 @@ async function harness(
|
|||||||
const connection = await provider.connect({
|
const connection = await provider.connect({
|
||||||
launch,
|
launch,
|
||||||
versions: [1],
|
versions: [1],
|
||||||
capabilities: PROVIDER_CAPABILITIES,
|
capabilities: connectCapabilities,
|
||||||
});
|
});
|
||||||
connections.push(connection);
|
connections.push(connection);
|
||||||
const events: ProviderEvent[] = [];
|
const events: ProviderEvent[] = [];
|
||||||
@@ -1192,6 +1193,20 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
|
|||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("subagent children are not projected when session.subsession is not negotiated", async () => {
|
||||||
|
const h = await harness(
|
||||||
|
"subagent",
|
||||||
|
{ MUSE_TEST_CHILD: "tool" },
|
||||||
|
undefined,
|
||||||
|
PROVIDER_CAPABILITIES.filter((capability) => capability !== "session.subsession"),
|
||||||
|
);
|
||||||
|
await h.open();
|
||||||
|
await h.prompt();
|
||||||
|
await h.wait((e) => e.type === "session.turn" && e.state === "completed");
|
||||||
|
expect(h.events.filter((e) => e.type === "session.opened" && e.parentSessionId)).toHaveLength(0);
|
||||||
|
expect((await h.recorded()).filter((f) => f.method === "session/read")).toEqual([]);
|
||||||
|
});
|
||||||
test("gap backfill repairs a missing completion without duplicating item revisions", async () => {
|
test("gap backfill repairs a missing completion without duplicating item revisions", async () => {
|
||||||
const h = await harness("text-reasoning", { MUSE_TEST_GAP: "1" });
|
const h = await harness("text-reasoning", { MUSE_TEST_GAP: "1" });
|
||||||
await h.open();
|
await h.open();
|
||||||
|
|||||||
@@ -289,7 +289,8 @@ servers during `session.open`. There is no separate reload operation.
|
|||||||
|
|
||||||
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
|
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
|
||||||
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
|
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
|
||||||
recreate it while restoring the parent.
|
recreate it while restoring the parent. The parent's `session.opened` capabilities must include
|
||||||
|
`session.subsession`. Paseo fails the whole connection when a parent without it opens a child.
|
||||||
|
|
||||||
## Add commands and steering
|
## Add commands and steering
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user