210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { SessionFocusController } from "@oh-my-pi/pi-coding-agent/modes/controllers/session-focus-controller";
|
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
|
|
import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
|
|
interface SessionStub {
|
|
session: AgentSession;
|
|
/** Emit an event through the listener captured by the last subscribe(). */
|
|
emit: (event: unknown) => Promise<void>;
|
|
unsubscribeCalls: () => number;
|
|
setStreaming: (streaming: boolean) => void;
|
|
}
|
|
|
|
function makeSessionStub(opts: { isStreaming?: boolean } = {}): SessionStub {
|
|
let listener: ((event: AgentSessionEvent) => Promise<void> | void) | undefined;
|
|
let unsubscribeCalls = 0;
|
|
const stub = {
|
|
isStreaming: opts.isStreaming ?? false,
|
|
subscribe(fn: (event: AgentSessionEvent) => Promise<void> | void) {
|
|
listener = fn;
|
|
return () => {
|
|
unsubscribeCalls++;
|
|
};
|
|
},
|
|
};
|
|
return {
|
|
session: stub as unknown as AgentSession,
|
|
emit: async event => {
|
|
if (!listener) throw new Error("no listener captured: subscribe() was never called");
|
|
await listener(event as AgentSessionEvent);
|
|
},
|
|
unsubscribeCalls: () => unsubscribeCalls,
|
|
setStreaming: streaming => {
|
|
stub.isStreaming = streaming;
|
|
},
|
|
};
|
|
}
|
|
|
|
interface Harness {
|
|
ctx: InteractiveModeContext;
|
|
controller: SessionFocusController;
|
|
registry: AgentRegistry;
|
|
main: SessionStub;
|
|
handledEvents: unknown[];
|
|
setSessionCalls: Array<[AgentSession, string | undefined]>;
|
|
counts: {
|
|
clearTransientSessionUi: () => number;
|
|
resetTranscriptAnchors: () => number;
|
|
renderInitialMessages: () => number;
|
|
mainUnsubscribe: () => number;
|
|
};
|
|
}
|
|
|
|
function makeHarness(): Harness {
|
|
const main = makeSessionStub();
|
|
const handledEvents: unknown[] = [];
|
|
const setSessionCalls: Array<[AgentSession, string | undefined]> = [];
|
|
let clearTransientSessionUi = 0;
|
|
let resetTranscriptAnchors = 0;
|
|
let renderInitialMessages = 0;
|
|
let mainUnsubscribe = 0;
|
|
|
|
const ctx = {
|
|
session: main.session,
|
|
unsubscribe: () => {
|
|
mainUnsubscribe++;
|
|
},
|
|
eventController: {
|
|
handleEvent: async (event: unknown) => {
|
|
handledEvents.push(event);
|
|
},
|
|
resetTranscriptAnchors: () => {
|
|
resetTranscriptAnchors++;
|
|
},
|
|
},
|
|
statusLine: {
|
|
setSession: (session: AgentSession, focusedAgentId?: string) => {
|
|
setSessionCalls.push([session, focusedAgentId]);
|
|
},
|
|
invalidate() {},
|
|
},
|
|
clearTransientSessionUi: () => {
|
|
clearTransientSessionUi++;
|
|
},
|
|
renderInitialMessages: () => {
|
|
renderInitialMessages++;
|
|
},
|
|
updateEditorBorderColor() {},
|
|
ui: { requestRender() {} },
|
|
showStatus() {},
|
|
collabGuest: undefined,
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
const registry = new AgentRegistry();
|
|
const lifecycle = new AgentLifecycleManager(registry);
|
|
const controller = new SessionFocusController(ctx, registry, () => lifecycle);
|
|
|
|
return {
|
|
ctx,
|
|
controller,
|
|
registry,
|
|
main,
|
|
handledEvents,
|
|
setSessionCalls,
|
|
counts: {
|
|
clearTransientSessionUi: () => clearTransientSessionUi,
|
|
resetTranscriptAnchors: () => resetTranscriptAnchors,
|
|
renderInitialMessages: () => renderInitialMessages,
|
|
mainUnsubscribe: () => mainUnsubscribe,
|
|
},
|
|
};
|
|
}
|
|
|
|
function registerSub(registry: AgentRegistry, id: string, session: AgentSession, parentId?: string) {
|
|
return registry.register({ id, displayName: id, kind: "sub", parentId, session, status: "running" });
|
|
}
|
|
|
|
/** Settle the async unfocus chain (registry event → void unfocus() → #attach). */
|
|
async function flushAsync(): Promise<void> {
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
}
|
|
|
|
describe("SessionFocusController", () => {
|
|
it("focusAgent retargets subscription, transcript anchors, and status line onto the worker session", async () => {
|
|
const h = makeHarness();
|
|
const worker = makeSessionStub();
|
|
registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID);
|
|
|
|
await h.controller.focusAgent("Worker");
|
|
|
|
expect(h.controller.focusedAgentId).toBe("Worker");
|
|
expect(h.controller.target).toBe(worker.session);
|
|
expect(h.counts.mainUnsubscribe()).toBe(1);
|
|
expect(h.counts.clearTransientSessionUi()).toBe(1);
|
|
expect(h.counts.resetTranscriptAnchors()).toBe(1);
|
|
expect(h.counts.renderInitialMessages()).toBe(1);
|
|
expect(h.setSessionCalls).toEqual([[worker.session, "Worker"]]);
|
|
|
|
const event = { type: "message_start", message: { role: "user" } };
|
|
await worker.emit(event);
|
|
expect(h.handledEvents).toEqual([event]);
|
|
});
|
|
|
|
it("mid-turn attach synthesizes agent_start, and an orphaned assistant message_update gets a synthesized message_start", async () => {
|
|
const h = makeHarness();
|
|
const worker = makeSessionStub({ isStreaming: true });
|
|
registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID);
|
|
|
|
await h.controller.focusAgent("Worker");
|
|
expect(h.handledEvents).toEqual([{ type: "agent_start" }]);
|
|
|
|
const message = { role: "assistant", content: "partial" };
|
|
await worker.emit({ type: "message_update", message });
|
|
expect(h.handledEvents.slice(1)).toEqual([
|
|
{ type: "message_start", message },
|
|
{ type: "message_update", message },
|
|
]);
|
|
|
|
// Guard fires once: subsequent updates pass through unsynthesized.
|
|
await worker.emit({ type: "message_update", message });
|
|
expect(h.handledEvents.slice(3)).toEqual([{ type: "message_update", message }]);
|
|
});
|
|
|
|
it("focusParent walks parentId to a registered non-main agent, then re-attaches the main session", async () => {
|
|
const h = makeHarness();
|
|
const parent = makeSessionStub();
|
|
const worker = makeSessionStub();
|
|
registerSub(h.registry, "Parent", parent.session, MAIN_AGENT_ID);
|
|
registerSub(h.registry, "Worker", worker.session, "Parent");
|
|
|
|
await h.controller.focusAgent("Worker");
|
|
await h.controller.focusParent();
|
|
expect(h.controller.focusedAgentId).toBe("Parent");
|
|
expect(h.setSessionCalls).toEqual([
|
|
[worker.session, "Worker"],
|
|
[parent.session, "Parent"],
|
|
]);
|
|
|
|
// Parent's parent is Main → unfocus back to ctx.session.
|
|
await h.controller.focusParent();
|
|
expect(h.controller.focusedAgentId).toBeUndefined();
|
|
expect(h.controller.target).toBeUndefined();
|
|
expect(h.setSessionCalls).toEqual([
|
|
[worker.session, "Worker"],
|
|
[parent.session, "Parent"],
|
|
[h.main.session, undefined],
|
|
]);
|
|
});
|
|
|
|
it("parking the focused agent auto-unfocuses back to the main session", async () => {
|
|
const h = makeHarness();
|
|
const worker = makeSessionStub();
|
|
registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID);
|
|
|
|
await h.controller.focusAgent("Worker");
|
|
expect(h.controller.focusedAgentId).toBe("Worker");
|
|
|
|
h.registry.setStatus("Worker", "parked");
|
|
await flushAsync();
|
|
|
|
expect(h.controller.focusedAgentId).toBeUndefined();
|
|
expect(h.setSessionCalls).toEqual([
|
|
[worker.session, "Worker"],
|
|
[h.main.session, undefined],
|
|
]);
|
|
});
|
|
});
|