Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andGeorge 59e63fbb5e test(muse): exercise child history replay without a live setup turn
Seed a historical child after closing an empty session so the replay
regression reaches resume even when live subagent projection is broken.
Both paths now fail independently with the reported negotiation error.

Co-authored-by: George <90179633+georgepullen@users.noreply.github.com>
2026-10-28 11:41:11 +00:00
George 51e844b540 test(plugins): launch fake muse through the provider command override 2026-10-28 11:38:22 +00:00
George 367a9d908b test(plugins): cover muse subsession negotiation through PluginRuntime 2026-10-28 11:38:22 +00:00
George eef309ec06 docs(plugins): require session.subsession negotiation before opening a child 2026-10-28 11:38:22 +00:00
George d75f60ebcc test(muse-provider): reach the subsession gate with a child-bearing fixture 2026-10-28 11:38:22 +00:00
George 6a4c05ce82 fix(muse-provider): negotiate session.subsession and gate child projection 2026-10-28 11:38:21 +00:00
6 changed files with 150 additions and 3 deletions
@@ -358,6 +358,125 @@ register(${JSON.stringify(guardUrl)});`;
await adapters.shutdown();
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 () => {
const directory = await createPlugin(
+5
View File
@@ -21,6 +21,7 @@ export class Children {
private readonly parentId: string,
private readonly cwd: string,
private readonly emit: (event: ProviderEvent) => void,
private readonly subsessions: boolean,
) {}
async update(item: WireItem): Promise<void> {
@@ -34,6 +35,10 @@ export class Children {
)
)
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;
if (!this.children.has(id)) {
const { session } = await this.host.request(
+1
View File
@@ -27,6 +27,7 @@ const capabilities = [
"session.persistence",
"permission",
"session.list",
"session.subsession",
] as const;
export function createMuseProvider(usage: Usage): ProviderRegistration {
+7 -1
View File
@@ -142,7 +142,13 @@ export class Session {
const nativeId = response.session.sessionId;
this.liveState = {
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(
this.host,
nativeId,
+16 -1
View File
@@ -45,6 +45,7 @@ async function harness(
scenario = "text-reasoning",
env: Record<string, string> = {},
providerOptions?: Record<string, unknown>,
connectCapabilities: readonly string[] = PROVIDER_CAPABILITIES,
) {
const root = await mkdtemp(path.join(os.tmpdir(), "muse-provider-test-"));
roots.push(root);
@@ -62,7 +63,7 @@ async function harness(
const connection = await provider.connect({
launch,
versions: [1],
capabilities: PROVIDER_CAPABILITIES,
capabilities: connectCapabilities,
});
connections.push(connection);
const events: ProviderEvent[] = [];
@@ -1192,6 +1193,20 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
).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 () => {
const h = await harness("text-reasoning", { MUSE_TEST_GAP: "1" });
await h.open();
+2 -1
View File
@@ -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
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