mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1294c1973e | ||
|
|
030b471dba | ||
|
|
0ca959c518 | ||
|
|
75bbd9ac42 | ||
|
|
2e81768969 | ||
|
|
8a349c6045 |
@@ -1,6 +1,6 @@
|
||||
import type { ProviderEvent } from "@getpaseo/plugin/server/provider";
|
||||
import { MspConnection, type Notification } from "./connection.js";
|
||||
import { Timeline } from "./timeline.js";
|
||||
import { Timeline, subagentOutcome } from "./timeline.js";
|
||||
import {
|
||||
childSessionSchema,
|
||||
pageSchema,
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
type WireItem,
|
||||
} from "./wire.js";
|
||||
|
||||
// Match the existing silence-recovery cadence, including after the parent becomes idle.
|
||||
const READ_RETRY_MS = 2 * 60 * 1000;
|
||||
|
||||
interface Child {
|
||||
timeline: Timeline;
|
||||
cursor: string | undefined;
|
||||
ended: boolean;
|
||||
reading: Promise<void> | undefined;
|
||||
retry: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
export class Children {
|
||||
private readonly children = new Map<string, Child>();
|
||||
private stopped = false;
|
||||
constructor(
|
||||
private readonly host: MspConnection,
|
||||
private readonly parentId: string,
|
||||
@@ -35,28 +42,87 @@ export class Children {
|
||||
)
|
||||
return;
|
||||
const id = item.childSessionId;
|
||||
if (!this.children.has(id)) {
|
||||
const { session } = await this.host.request(
|
||||
if (!this.children.has(id)) await this.open(id, item);
|
||||
await this.read(id);
|
||||
this.end(id, item);
|
||||
}
|
||||
// A child's transcript is a projection: Muse 1.4.7 can refuse to read a subagent's view right
|
||||
// after creating it, and that must not fail the parent. A child whose session/read fails still
|
||||
// opens, without its title and workspace, so a later item update or refresh() can read it: the
|
||||
// item may be the child's last revision, which a replay would not deliver again.
|
||||
private async open(id: string, item: WireItem): Promise<void> {
|
||||
let session;
|
||||
try {
|
||||
({ session } = await this.host.request(
|
||||
"session/read",
|
||||
{ sessionId: id },
|
||||
childSessionSchema,
|
||||
);
|
||||
this.emit({
|
||||
type: "session.opened",
|
||||
sessionId: id,
|
||||
parentSessionId: this.parentId,
|
||||
toolCallId: item.callId ?? item.itemId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: session.workspaceRoot ?? this.cwd,
|
||||
title: session.title,
|
||||
});
|
||||
this.children.set(id, {
|
||||
timeline: new Timeline(this.host, id, id, this.emit),
|
||||
cursor: undefined,
|
||||
});
|
||||
));
|
||||
} catch (error) {
|
||||
this.readFailed(id, error);
|
||||
}
|
||||
await this.page(id);
|
||||
this.emit({
|
||||
type: "session.opened",
|
||||
sessionId: id,
|
||||
parentSessionId: this.parentId,
|
||||
toolCallId: item.callId ?? item.itemId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: session?.workspaceRoot ?? this.cwd,
|
||||
title: session?.title,
|
||||
});
|
||||
this.children.set(id, {
|
||||
timeline: new Timeline(this.host, id, id, this.emit),
|
||||
cursor: undefined,
|
||||
ended: false,
|
||||
reading: undefined,
|
||||
retry: undefined,
|
||||
});
|
||||
}
|
||||
// A failed final read must recover even after the parent finishes and stops its silence timer.
|
||||
// Each update reads fresh history after any older read, with serialized cursor advancement.
|
||||
private async read(id: string): Promise<void> {
|
||||
const child = this.children.get(id);
|
||||
if (!child) return;
|
||||
while (child.reading) await child.reading;
|
||||
if (this.stopped || this.children.get(id) !== child) return;
|
||||
clearTimeout(child.retry);
|
||||
child.retry = undefined;
|
||||
child.reading = this.page(id)
|
||||
.catch((error: unknown) => {
|
||||
this.readFailed(id, error);
|
||||
if (!this.stopped && this.children.get(id) === child)
|
||||
child.retry = setTimeout(() => {
|
||||
void this.read(id);
|
||||
}, READ_RETRY_MS);
|
||||
})
|
||||
.finally(() => {
|
||||
child.reading = undefined;
|
||||
});
|
||||
return child.reading;
|
||||
}
|
||||
private readFailed(id: string, error: unknown): void {
|
||||
process.stderr.write(
|
||||
`Muse child ${id} view read failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
}
|
||||
// Muse sends no turn events for a child, so the parent's item is the only record that it finished.
|
||||
// A subagent_* tool call completes when the spawn is accepted, so only subagent and workflow items count.
|
||||
private end(id: string, item: WireItem): void {
|
||||
const child = this.children.get(id)!;
|
||||
const outcome =
|
||||
item.kind === "subagent" || item.kind === "workflow" ? subagentOutcome(item) : undefined;
|
||||
if (!outcome || child.ended) return;
|
||||
child.ended = true;
|
||||
this.emit({
|
||||
type: "session.turn",
|
||||
sessionId: id,
|
||||
turnId: item.itemId,
|
||||
state: outcome,
|
||||
...(outcome === "failed"
|
||||
? { error: { message: item.result?.errorKind ?? "Muse subagent failed" } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
async notify(id: string, event: Notification): Promise<void> {
|
||||
const child = this.children.get(id);
|
||||
@@ -67,7 +133,7 @@ export class Children {
|
||||
} else if (event.method === "item/delta") child.timeline.delta(deltaSchema.parse(event.params));
|
||||
}
|
||||
async refresh(): Promise<void> {
|
||||
for (const id of this.children.keys()) await this.page(id);
|
||||
for (const id of this.children.keys()) await this.read(id);
|
||||
}
|
||||
private async page(id: string): Promise<void> {
|
||||
const child = this.children.get(id)!;
|
||||
@@ -87,7 +153,15 @@ export class Children {
|
||||
if (next !== null) child.cursor = next;
|
||||
} while (next !== null);
|
||||
}
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const child of this.children.values()) {
|
||||
clearTimeout(child.retry);
|
||||
child.retry = undefined;
|
||||
}
|
||||
}
|
||||
close(): void {
|
||||
this.stop();
|
||||
for (const sessionId of this.children.keys()) this.emit({ type: "session.closed", sessionId });
|
||||
this.children.clear();
|
||||
}
|
||||
|
||||
@@ -164,16 +164,18 @@ export class Session {
|
||||
await live.recovery.backfill();
|
||||
return live.children.refresh();
|
||||
})
|
||||
.catch((error) => this.runtimeFailed(error));
|
||||
.catch((error: unknown) => this.repairFailed(error));
|
||||
},
|
||||
),
|
||||
};
|
||||
this.providerId = response.session.providerId ?? undefined;
|
||||
const configured = { model: this.config.model, mode: this.config.mode };
|
||||
this.config = {
|
||||
...this.config,
|
||||
model: response.session.modelId ?? this.config.model,
|
||||
mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest",
|
||||
};
|
||||
if (input.persistence) await this.restoreControls(nativeId, configured);
|
||||
this.options.emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
@@ -205,6 +207,26 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
// session/resume takes no model or approval mode, and Muse can reload a session on its defaults.
|
||||
private async restoreControls(
|
||||
sessionId: string,
|
||||
configured: Pick<ProviderSessionConfig, "model" | "mode">,
|
||||
): Promise<void> {
|
||||
if (configured.model && configured.model !== this.config.model) {
|
||||
await this.host.command(
|
||||
"session/setModel",
|
||||
{ sessionId, model: { providerId: this.providerId, modelId: configured.model } },
|
||||
ackSchema,
|
||||
);
|
||||
this.config = { ...this.config, model: configured.model };
|
||||
}
|
||||
if (configured.mode && configured.mode !== this.config.mode) {
|
||||
const mode = approvalModeSchema.parse(configured.mode);
|
||||
await this.host.command("session/setApprovalMode", { sessionId, mode }, ackSchema);
|
||||
this.config = { ...this.config, mode };
|
||||
}
|
||||
}
|
||||
|
||||
private live(): Live {
|
||||
if (!this.liveState) throw new MuseError("sessionNotOpen", "Muse session is not open");
|
||||
return this.liveState;
|
||||
@@ -393,8 +415,17 @@ export class Session {
|
||||
.then(() => this.notify(notification))
|
||||
.catch((error: unknown) => this.runtimeFailed(error));
|
||||
}
|
||||
// The silence repair is best effort: the live view still delivers, so a failed page waits for the
|
||||
// next silence instead of failing the session. Host death reaches runtimeFailed through onExit.
|
||||
private repairFailed(error: unknown): void {
|
||||
process.stderr.write(
|
||||
`Muse silence repair failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
this.live().recovery.activity(undefined, this.activeTurnIds().length > 0);
|
||||
}
|
||||
private runtimeFailed(error: unknown): void {
|
||||
this.live().recovery.close();
|
||||
this.live().children.stop();
|
||||
this.options.emit({
|
||||
type: "session.runtime_failed",
|
||||
sessionId: this.options.id,
|
||||
@@ -410,7 +441,9 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
await this.processNotification(notification);
|
||||
this.live().recovery.activity(envelope.viewCursor, this.activeTurnIds().length > 0);
|
||||
// A projected subagent event carries the child's view cursor, which this session's view rejects.
|
||||
const cursor = envelope.subagentOrigin === undefined ? envelope.viewCursor : undefined;
|
||||
this.live().recovery.activity(cursor, this.activeTurnIds().length > 0);
|
||||
}
|
||||
private async processNotification(notification: Notification): Promise<void> {
|
||||
switch (notification.method) {
|
||||
|
||||
@@ -144,6 +144,16 @@ function toolCallItem(item: WireItem, patch?: string): ProviderTimelineItem {
|
||||
}
|
||||
: toolDetail(item, patch),
|
||||
} as const;
|
||||
if (item.kind === "subagent" || item.kind === "workflow") {
|
||||
const outcome = subagentOutcome(item);
|
||||
if (outcome === "failed")
|
||||
return {
|
||||
...base,
|
||||
status: outcome,
|
||||
error: item.result?.errorKind ?? item.failureReason ?? item.status,
|
||||
};
|
||||
return { ...base, status: outcome ?? "running", error: null };
|
||||
}
|
||||
if (item.status === "inProgress") return { ...base, status: "running", error: null };
|
||||
if (item.status === "cancelled") return { ...base, status: "canceled", error: null };
|
||||
if (item.status !== "completed")
|
||||
@@ -151,6 +161,18 @@ function toolCallItem(item: WireItem, patch?: string): ProviderTimelineItem {
|
||||
return { ...base, status: "completed", error: null };
|
||||
}
|
||||
|
||||
// Muse 1.4.7 leaves a finished subagent's item `inProgress`; its control status says the child is done.
|
||||
export function subagentOutcome(item: WireItem): "completed" | "failed" | "canceled" | undefined {
|
||||
if (item.status === "cancelled") return "canceled";
|
||||
if (item.status !== "inProgress" && item.status !== "completed") return "failed";
|
||||
const done =
|
||||
item.status === "completed" ||
|
||||
item.controlStatus === "resultReady" ||
|
||||
item.controlStatus === "closed";
|
||||
if (!done) return undefined;
|
||||
return item.result?.errorKind ? "failed" : "completed";
|
||||
}
|
||||
|
||||
export function appendDelta(
|
||||
previous: WireItem,
|
||||
delta: z.infer<typeof deltaSchema>,
|
||||
|
||||
@@ -28,6 +28,8 @@ export const itemSchema = z.object({
|
||||
entryId: z.string().optional(),
|
||||
kind: z.string(),
|
||||
status: z.string(),
|
||||
controlStatus: z.string().optional(),
|
||||
result: z.object({ errorKind: z.string().optional() }).nullish(),
|
||||
turnId: z.string().nullable().optional(),
|
||||
commandId: z.string().optional(),
|
||||
recordedAt: z.string().optional(),
|
||||
@@ -84,6 +86,8 @@ export type WireApproval = z.infer<typeof approvalSchema>;
|
||||
export const notificationSchema = z.object({
|
||||
sessionId: z.string().optional(),
|
||||
viewCursor: z.string().optional(),
|
||||
// Marks a subagent's event projected onto the parent's plane; its viewCursor is the child's.
|
||||
subagentOrigin: z.unknown().optional(),
|
||||
});
|
||||
export const frameSchema = z.object({
|
||||
jsonrpc: z.literal("2.0"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import { appendFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -24,12 +24,15 @@ const rows = readFixture(scenario);
|
||||
if (process.env.MUSE_TEST_REQUESTS)
|
||||
appendFileSync(
|
||||
process.env.MUSE_TEST_REQUESTS,
|
||||
JSON.stringify({ event: "hostStarted", args: process.argv.slice(2) }) + "\n",
|
||||
JSON.stringify({ event: "hostStarted", pid: process.pid, args: process.argv.slice(2) }) + "\n",
|
||||
);
|
||||
const catalogRows = readFixture("catalog-controls");
|
||||
const replacements = new Map();
|
||||
const counts = new Map();
|
||||
const receipts = new Set();
|
||||
let pageHung = false;
|
||||
let deferredChildItem;
|
||||
const failedChildReads = new Set();
|
||||
const lines = createInterface({ input: process.stdin });
|
||||
lines.on("line", receive);
|
||||
function receive(line) {
|
||||
@@ -78,6 +81,9 @@ function receiveFrame(line) {
|
||||
for (const row of terminalFirst) emitFixtureMessage(row.msg, recorded, frame);
|
||||
setTimeout(() => emitFixtureMessage(response.msg, recorded, frame), 50);
|
||||
} else for (const row of messages) emitFixtureMessage(row.msg, recorded, frame);
|
||||
// The parent's last live notifications before it waits on the subagent.
|
||||
if (process.env.MUSE_TEST_CHILD_APPROVAL && frame.method === "turn/start")
|
||||
projectChildApproval(frame.params.sessionId);
|
||||
}
|
||||
function controlResponse(frame) {
|
||||
if (frame.method === "session/resume" && process.env.MUSE_TEST_HISTORY) {
|
||||
@@ -110,10 +116,7 @@ function controlResponse(frame) {
|
||||
return true;
|
||||
}
|
||||
if (process.env.MUSE_TEST_HANG === frame.method) return true;
|
||||
if (frame.method === "session/setReasoningEffort") {
|
||||
respond(frame, { status: "accepted", commandId: frame.params.commandId });
|
||||
return true;
|
||||
}
|
||||
if (scriptedResponse(frame)) return true;
|
||||
if (frame.method === "account/read") {
|
||||
respond(frame, { credentialRequired: true, state: process.env.MUSE_TEST_ACCOUNT || "envKey" });
|
||||
return true;
|
||||
@@ -131,6 +134,91 @@ function controlResponse(frame) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Commands accepted without a recorded exchange, and the view/page failures some tests script.
|
||||
function scriptedResponse(frame) {
|
||||
if (
|
||||
frame.method === "session/setReasoningEffort" ||
|
||||
(process.env.MUSE_TEST_CHILD_APPROVAL && frame.method === "approval/decide") ||
|
||||
(process.env.MUSE_TEST_RESUMED_MODEL &&
|
||||
["session/setModel", "session/setApprovalMode"].includes(frame.method))
|
||||
) {
|
||||
respond(frame, { status: "accepted", commandId: frame.params.commandId });
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
deferredChildPage(frame) ||
|
||||
(frame.method === "view/page" && failedPage(frame)) ||
|
||||
failedChildRead(frame)
|
||||
);
|
||||
}
|
||||
// Hold an old child page until the consumer has received the final item update.
|
||||
function deferredChildPage(frame) {
|
||||
const releasePath = process.env.MUSE_TEST_CHILD_PAGE_RELEASE;
|
||||
if (
|
||||
!releasePath ||
|
||||
frame.method !== "view/page" ||
|
||||
frame.params.sessionId !== "fixture-child" ||
|
||||
!deferredChildItem ||
|
||||
!failedChildReads.has("view/page")
|
||||
)
|
||||
return false;
|
||||
send(deferredChildItem);
|
||||
deferredChildItem = undefined;
|
||||
function release() {
|
||||
if (!existsSync(releasePath)) {
|
||||
setTimeout(release, 5);
|
||||
return;
|
||||
}
|
||||
respond(frame, { events: [], nextCursor: null });
|
||||
}
|
||||
release();
|
||||
return true;
|
||||
}
|
||||
const childReadFailures = {
|
||||
"view/page": "MUSE_TEST_CHILD_PAGE_FAIL",
|
||||
"session/read": "MUSE_TEST_CHILD_READ_FAIL",
|
||||
};
|
||||
// Muse 1.4.7 can refuse to read a subagent's view right after creating it ("view range read failed").
|
||||
function failedChildRead(frame) {
|
||||
const failure = process.env[childReadFailures[frame.method]];
|
||||
if (!failure || frame.params.sessionId !== "fixture-child") return false;
|
||||
if (failure === "once" && failedChildReads.has(frame.method)) return false;
|
||||
if (failure === "twice") {
|
||||
const key = `child-fail:${frame.method}`;
|
||||
const count = counts.get(key) ?? 0;
|
||||
if (count >= 2) return false;
|
||||
counts.set(key, count + 1);
|
||||
}
|
||||
failedChildReads.add(frame.method);
|
||||
rpcError(frame, -32603, "internal", "view range read failed");
|
||||
return true;
|
||||
}
|
||||
function failedPage(frame) {
|
||||
const anchor = frame.params.cursor;
|
||||
// Real Muse rejects an anchor from another session's view the same way (live probe, Muse 1.4.7).
|
||||
if (
|
||||
process.env.MUSE_TEST_CHILD_APPROVAL &&
|
||||
anchor &&
|
||||
anchor.split(":")[1] !== frame.params.sessionId
|
||||
) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: frame.id,
|
||||
error: {
|
||||
code: -32004,
|
||||
message: "unknown cursor anchor",
|
||||
data: { kind: "notFound", reason: "missingAnchor", anchor },
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Never answering lets the plugin's own request timeout fail the repair on the test's fake clock.
|
||||
if (process.env.MUSE_TEST_PAGE_HANG_ONCE && !pageHung) {
|
||||
pageHung = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function parityResponse(frame) {
|
||||
if (workflowResponse(frame)) return true;
|
||||
if (frame.method === "view/page") {
|
||||
@@ -341,8 +429,25 @@ function applyWireVariants(message) {
|
||||
message.result.session.modelId = null;
|
||||
message.result.session.providerId = null;
|
||||
}
|
||||
// Muse 1.4.7 can resume a session on its default model and approval mode.
|
||||
if (message.result?.session && message.result.history && process.env.MUSE_TEST_RESUMED_MODEL) {
|
||||
message.result.session.modelId = process.env.MUSE_TEST_RESUMED_MODEL;
|
||||
message.result.session.approvalMode = {
|
||||
lastCommandId: null,
|
||||
mode: "onRequest",
|
||||
source: "replay",
|
||||
};
|
||||
}
|
||||
}
|
||||
function parityDelivery(message) {
|
||||
if (
|
||||
process.env.MUSE_TEST_CHILD_PAGE_RELEASE &&
|
||||
message.method === "item/completed" &&
|
||||
message.params.item?.childSessionId === "fixture-child"
|
||||
) {
|
||||
deferredChildItem = message;
|
||||
return true;
|
||||
}
|
||||
if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed")
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
@@ -379,6 +484,42 @@ function parityDelivery(message) {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// A subagent's approval projected onto the parent's plane, shaped like Muse 1.4.7's: the parent's
|
||||
// sessionId, but a viewCursor and sourceRange from the child's stream.
|
||||
function projectChildApproval(sessionId) {
|
||||
const child = "fixture-child";
|
||||
const subagentOrigin = { subagentId: "subagent-1", childSessionId: child, childRunId: child };
|
||||
const sourceRange = {
|
||||
stream: { kind: "session", id: child },
|
||||
first: { id: "fixture-child-record", sequence: 2 },
|
||||
last: { id: "fixture-child-record", sequence: 2 },
|
||||
};
|
||||
const approvalId = "fixture-child-approval";
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "approval/requested",
|
||||
params: {
|
||||
sessionId,
|
||||
approvalId,
|
||||
itemId: "fixture-child-tool",
|
||||
toolName: "bash",
|
||||
subject: { kind: "shell", command: "FOO=1 ls ~" },
|
||||
currentRequirementId: { approvalId, sourceIndex: 0 },
|
||||
availableChoices: [
|
||||
{ choiceId: "approve-once", decision: "approved", scope: "once", label: "Approve once" },
|
||||
{ choiceId: "deny-once", decision: "denied", scope: "once", label: "Deny" },
|
||||
],
|
||||
sourceRange,
|
||||
subagentOrigin,
|
||||
viewCursor: `v:${child}:2`,
|
||||
},
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "approval/resolved",
|
||||
params: { sessionId, approvalId, sourceRange, subagentOrigin, viewCursor: `v:${child}:3` },
|
||||
});
|
||||
}
|
||||
function applyTestVariants(message) {
|
||||
applyParityVariants(message);
|
||||
if (message.method === "turn/completed" && process.env.MUSE_TEST_INTERRUPT_TERMINAL)
|
||||
@@ -413,6 +554,14 @@ function applyParityVariants(message) {
|
||||
message.params.item.role = "worker";
|
||||
message.params.item.objective = "Child objective";
|
||||
}
|
||||
// Muse 1.4.7 leaves a finished subagent `inProgress` and reports the end in controlStatus.
|
||||
if (process.env.MUSE_TEST_CHILD_OUTCOME && message.method === "item/completed") {
|
||||
message.params.item.status = "inProgress";
|
||||
message.params.item.controlStatus = "resultReady";
|
||||
message.params.item.result = { summary: "Child done", evidenceRefs: [], artifactRefs: [] };
|
||||
if (process.env.MUSE_TEST_CHILD_OUTCOME === "failed")
|
||||
message.params.item.result.errorKind = "childFailed";
|
||||
}
|
||||
}
|
||||
if (message.params?.availableChoices && process.env.MUSE_TEST_DENIED)
|
||||
message.params.availableChoices.push({
|
||||
|
||||
@@ -138,6 +138,15 @@ async function harness(
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
async function waitForRequests(method: string, count: number) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const sent = (await recorded()).filter((frame) => frame.method === method);
|
||||
if (sent.length >= count) return sent;
|
||||
await delay(10);
|
||||
}
|
||||
throw new Error(`Muse never received ${count} ${method} request(s)`);
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
launch,
|
||||
@@ -148,6 +157,7 @@ async function harness(
|
||||
open,
|
||||
prompt,
|
||||
recorded,
|
||||
waitForRequests,
|
||||
root,
|
||||
usageSource,
|
||||
};
|
||||
@@ -406,6 +416,39 @@ for (const cursor of [undefined, "v:old:7"]) {
|
||||
).not.toHaveProperty("cursor");
|
||||
});
|
||||
}
|
||||
for (const source of ["agent config", "saved session"] as const) {
|
||||
test(`resume re-applies the ${source} model and approval mode when Muse reports its defaults`, async () => {
|
||||
const h = await harness("resume-without-cursor", {
|
||||
MUSE_TEST_RESUMED_MODEL: "meta/muse-default",
|
||||
});
|
||||
await h.open(
|
||||
{ version: 1, data: { sessionId: "saved-session", model: "meta/muse-spark-1.3" } },
|
||||
"allowAll",
|
||||
source === "agent config" ? "meta/muse-spark-1.3" : null,
|
||||
);
|
||||
const frames = await h.recorded();
|
||||
expect(frames.find((f) => f.method === "session/setModel")?.params.model).toMatchObject({
|
||||
modelId: "meta/muse-spark-1.3",
|
||||
});
|
||||
expect(frames.find((f) => f.method === "session/setApprovalMode")?.params.mode).toBe(
|
||||
"allowAll",
|
||||
);
|
||||
expect(h.events.findLast((e) => e.type === "session.config")).toMatchObject({
|
||||
config: { model: "meta/muse-spark-1.3", mode: "allowAll" },
|
||||
});
|
||||
expect(h.events.findLast((e) => e.type === "session.persistence")).toMatchObject({
|
||||
persistence: { data: { model: "meta/muse-spark-1.3" } },
|
||||
});
|
||||
});
|
||||
}
|
||||
test("resume sends no model or approval change when Muse already matches", async () => {
|
||||
const h = await harness("resume-without-cursor");
|
||||
await h.open({ version: 1, data: { sessionId: "saved-session" } });
|
||||
const frames = await h.recorded();
|
||||
expect(
|
||||
frames.filter((f) => ["session/setModel", "session/setApprovalMode"].includes(f.method)),
|
||||
).toEqual([]);
|
||||
});
|
||||
test("steer sends ifBusy steer and joins the running turn", async () => {
|
||||
const h = await harness("steer");
|
||||
await h.open();
|
||||
@@ -1173,10 +1216,17 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
|
||||
e.item.type === "assistant_message",
|
||||
),
|
||||
).toMatchObject({ item: { text: "SUBAGENT_PHASE0_OK" } });
|
||||
await h.wait((e) => e.type === "session.turn" && e.state === "completed");
|
||||
await h.wait(
|
||||
(e) =>
|
||||
e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"),
|
||||
).toHaveLength(1);
|
||||
// A completed subagent_spawn tool call only means the spawn was accepted.
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
|
||||
).toHaveLength(variant === "tool" ? 0 : 1);
|
||||
expect(
|
||||
(await h.recorded())
|
||||
.filter((f) => f.method === "session/read")
|
||||
@@ -1192,6 +1242,97 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
for (const outcome of ["ready", "failed"] as const) {
|
||||
test(`a subagent Muse leaves in progress with a ${outcome} result ends its child and its card`, async () => {
|
||||
const h = await harness("subagent", {
|
||||
MUSE_TEST_CHILD: "dedicated",
|
||||
MUSE_TEST_CHILD_OUTCOME: outcome,
|
||||
});
|
||||
await h.open();
|
||||
await h.prompt();
|
||||
const state = outcome === "ready" ? "completed" : "failed";
|
||||
expect(
|
||||
await h.wait((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
|
||||
).toMatchObject({
|
||||
state,
|
||||
...(outcome === "failed" ? { error: { message: "childFailed" } } : {}),
|
||||
});
|
||||
await h.wait(
|
||||
(e) =>
|
||||
e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
const cards = h.events.filter(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "paseo-session" &&
|
||||
e.item.type === "tool_call" &&
|
||||
e.item.name === "subagent",
|
||||
);
|
||||
expect(cards.at(-1)).toMatchObject({ item: { status: state } });
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
async function childReadHarness(env: Record<string, string>) {
|
||||
const h = await harness("subagent", { MUSE_TEST_CHILD: "dedicated", ...env });
|
||||
await h.open();
|
||||
await h.prompt();
|
||||
await h.wait(
|
||||
(e) =>
|
||||
(e.type === "session.turn" && e.sessionId === "paseo-session" && e.state !== "started") ||
|
||||
e.type === "session.runtime_failed",
|
||||
);
|
||||
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "paseo-session"),
|
||||
).toContainEqual(expect.objectContaining({ state: "completed" }));
|
||||
return h;
|
||||
}
|
||||
for (const failure of ["once", "always"] as const) {
|
||||
test(`a child view/page that fails ${failure} leaves the parent session running`, async () => {
|
||||
const h = await childReadHarness({ MUSE_TEST_CHILD_PAGE_FAIL: failure });
|
||||
// The next update of the subagent's item reads the child again.
|
||||
expect(
|
||||
(await h.recorded()).filter(
|
||||
(f) => f.method === "view/page" && f.params.sessionId === "fixture-child",
|
||||
).length,
|
||||
).toBeGreaterThan(1);
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
h.events.filter(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "fixture-child" &&
|
||||
e.item.type === "assistant_message",
|
||||
),
|
||||
).toHaveLength(failure === "once" ? 1 : 0);
|
||||
// The parent's item still settles the child, transcript or not.
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
|
||||
).toEqual([expect.objectContaining({ state: "completed" })]);
|
||||
});
|
||||
}
|
||||
test("a child whose session/read always fails still opens, shows its transcript, and ends", async () => {
|
||||
const h = await childReadHarness({ MUSE_TEST_CHILD_READ_FAIL: "always" });
|
||||
// Without its session it opens in the parent's workspace and untitled.
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"),
|
||||
).toEqual([expect.objectContaining({ cwd: h.root, title: undefined })]);
|
||||
expect(
|
||||
h.events.find(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "fixture-child" &&
|
||||
e.item.type === "assistant_message",
|
||||
),
|
||||
).toMatchObject({ item: { text: "SUBAGENT_PHASE0_OK" } });
|
||||
expect(
|
||||
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
|
||||
).toEqual([expect.objectContaining({ state: "completed" })]);
|
||||
});
|
||||
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();
|
||||
@@ -1220,6 +1361,54 @@ test("silent active turns page after two minutes and stop polling on recovered t
|
||||
await vi.advanceTimersByTimeAsync(240000);
|
||||
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1);
|
||||
});
|
||||
test("a subagent approval does not move the parent's silence repair onto the child's cursor", async () => {
|
||||
const h = await harness("text-reasoning", {
|
||||
MUSE_TEST_SILENT: "1",
|
||||
MUSE_TEST_CHILD_APPROVAL: "1",
|
||||
});
|
||||
await h.open(undefined, "allowAll");
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait((e) => e.type === "session.permission_resolved");
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
await h.wait(
|
||||
(e) =>
|
||||
(e.type === "session.turn" && e.state === "completed") || e.type === "session.runtime_failed",
|
||||
);
|
||||
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
|
||||
const frames = await h.recorded();
|
||||
expect(frames.find((f) => f.method === "approval/decide").params.approvalId).toBe(
|
||||
"fixture-child-approval",
|
||||
);
|
||||
const pages = frames.filter((f) => f.method === "view/page");
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0].params.cursor.split(":")[1]).toBe(pages[0].params.sessionId);
|
||||
});
|
||||
test("a failed silence repair keeps the session and retries at the next silence", async () => {
|
||||
const h = await harness("text-reasoning", {
|
||||
MUSE_TEST_SILENT: "1",
|
||||
MUSE_TEST_PAGE_HANG_ONCE: "1",
|
||||
});
|
||||
await h.open();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait((e) => e.type === "session.prompt_result");
|
||||
// The first repair's page is never answered. The silence, that page's request timeout and the
|
||||
// re-armed watchdog all run on the fake clock, one timer per step, whatever the timeout length.
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
await h.waitForRequests("view/page", 1);
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
await h.wait(
|
||||
(e) =>
|
||||
(e.type === "session.turn" && e.state === "completed") || e.type === "session.runtime_failed",
|
||||
);
|
||||
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
|
||||
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(2);
|
||||
expect(h.events.filter((e) => e.type === "session.turn" && e.state === "completed")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
test("session readiness accepts initialization beyond the old event polling window", async () => {
|
||||
const h = await harness("text-reasoning", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" });
|
||||
expect(await h.open()).toMatchObject({ type: "session.ready" });
|
||||
@@ -1743,3 +1932,102 @@ test("Muse reports belong only to the session's account and remain available glo
|
||||
expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]);
|
||||
expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds);
|
||||
});
|
||||
|
||||
test("a child history read recovers after the parent finishes", async () => {
|
||||
const h = await harness("subagent", {
|
||||
MUSE_TEST_CHILD: "dedicated",
|
||||
MUSE_TEST_CHILD_PAGE_FAIL: "twice",
|
||||
});
|
||||
await h.open();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait(
|
||||
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
|
||||
expect(
|
||||
h.events.some(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "fixture-child" &&
|
||||
e.item.type === "assistant_message",
|
||||
),
|
||||
).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
await h.wait(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "fixture-child" &&
|
||||
e.item.type === "assistant_message",
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
test("closing a session cancels pending child history retries", async () => {
|
||||
const h = await harness("subagent", {
|
||||
MUSE_TEST_CHILD: "dedicated",
|
||||
MUSE_TEST_CHILD_PAGE_FAIL: "always",
|
||||
});
|
||||
await h.open();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait(
|
||||
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
await h.send({ type: "session.close", sessionId: "paseo-session" });
|
||||
await h.wait((e) => e.type === "session.closed" && e.sessionId === "paseo-session");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("a final child update refreshes history after an older retry read", async () => {
|
||||
const h = await harness("subagent", {
|
||||
MUSE_TEST_CHILD: "dedicated",
|
||||
MUSE_TEST_CHILD_PAGE_FAIL: "once",
|
||||
});
|
||||
const release = path.join(h.root, "release-child-page");
|
||||
h.launch.env.MUSE_TEST_CHILD_PAGE_RELEASE = release;
|
||||
await h.open();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait(
|
||||
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(120000);
|
||||
await h.wait(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "paseo-session" &&
|
||||
e.item.type === "tool_call" &&
|
||||
e.item.name === "subagent" &&
|
||||
e.item.status === "completed",
|
||||
);
|
||||
await writeFile(release, "release");
|
||||
await h.wait(
|
||||
(e) => e.type === "session.turn" && e.sessionId === "fixture-child" && e.state === "completed",
|
||||
);
|
||||
expect(
|
||||
h.events.some(
|
||||
(e) =>
|
||||
e.type === "timeline.item" &&
|
||||
e.sessionId === "fixture-child" &&
|
||||
e.item.type === "assistant_message" &&
|
||||
e.item.text === "SUBAGENT_PHASE0_OK",
|
||||
),
|
||||
).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
test("native process exit stops child history retry timers", async () => {
|
||||
const h = await harness("subagent", {
|
||||
MUSE_TEST_CHILD: "dedicated",
|
||||
MUSE_TEST_CHILD_PAGE_FAIL: "always",
|
||||
});
|
||||
await h.open();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
await h.prompt();
|
||||
await h.wait(
|
||||
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
|
||||
);
|
||||
const host = (await h.recorded()).find((f) => f.event === "hostStarted");
|
||||
process.kill(host.pid, "SIGKILL");
|
||||
await h.wait((e) => e.type === "session.runtime_failed");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user