- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
931 lines
34 KiB
TypeScript
931 lines
34 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|
mockCreateFnAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@fusion/engine", () => ({
|
|
createFnAgent: mockCreateFnAgent,
|
|
}));
|
|
|
|
import {
|
|
__resetMissionInterviewState,
|
|
cancelMissionInterviewSession,
|
|
checkRateLimit,
|
|
cleanupMissionInterviewSession,
|
|
createMissionInterviewSession,
|
|
retryMissionInterviewSession,
|
|
getMissionInterviewSession,
|
|
getMissionInterviewSummary,
|
|
getRateLimitResetTime,
|
|
InvalidSessionStateError,
|
|
missionInterviewStreamManager,
|
|
parseMissionAgentResponse,
|
|
rehydrateFromStore,
|
|
setAiSessionStore,
|
|
RateLimitError,
|
|
SessionNotFoundError,
|
|
submitMissionInterviewResponse,
|
|
} from "../mission-interview.js";
|
|
import {
|
|
setDiagnosticsSink,
|
|
resetDiagnosticsSink,
|
|
} from "../ai-session-diagnostics.js";
|
|
import type { LogEntry } from "../ai-session-diagnostics.js";
|
|
import { EventEmitter } from "node:events";
|
|
import type { AiSessionRow } from "../ai-session-store.js";
|
|
|
|
function createQuestionJson(id = "q-1"): string {
|
|
return JSON.stringify({
|
|
type: "question",
|
|
data: {
|
|
id,
|
|
type: "text",
|
|
question: "What should we build first?",
|
|
description: "Initial scope",
|
|
},
|
|
});
|
|
}
|
|
|
|
function createCompleteJson(): string {
|
|
return JSON.stringify({
|
|
type: "complete",
|
|
data: {
|
|
missionTitle: "Mission Ready",
|
|
missionDescription: "Complete plan",
|
|
milestones: [
|
|
{
|
|
title: "Milestone 1",
|
|
slices: [
|
|
{
|
|
title: "Slice 1",
|
|
features: [
|
|
{ title: "Feature 1", acceptanceCriteria: "Works" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
function createMockAgent(responses: string[]) {
|
|
const queue = [...responses];
|
|
const messages: Array<{ role: string; content: string }> = [];
|
|
|
|
return {
|
|
session: {
|
|
state: { messages },
|
|
prompt: vi.fn(async () => {
|
|
const response = queue.shift() ?? createQuestionJson("q-fallback");
|
|
messages.push({ role: "assistant", content: response });
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForCurrentQuestion(sessionId: string): Promise<void> {
|
|
for (let i = 0; i < 50; i++) {
|
|
if (getMissionInterviewSession(sessionId)?.currentQuestion) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("Timed out waiting for currentQuestion");
|
|
}
|
|
|
|
class MockAiSessionStore extends EventEmitter {
|
|
rows = new Map<string, AiSessionRow>();
|
|
|
|
upsert(row: AiSessionRow): void {
|
|
this.rows.set(row.id, row);
|
|
}
|
|
|
|
updateThinking(id: string, thinkingOutput: string): void {
|
|
const row = this.rows.get(id);
|
|
if (!row) return;
|
|
this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString() });
|
|
}
|
|
|
|
delete(id: string): void {
|
|
this.rows.delete(id);
|
|
this.emit("ai_session:deleted", id);
|
|
}
|
|
|
|
get(id: string): AiSessionRow | null {
|
|
return this.rows.get(id) ?? null;
|
|
}
|
|
|
|
listRecoverable(): AiSessionRow[] {
|
|
return [...this.rows.values()].filter(
|
|
(row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error",
|
|
);
|
|
}
|
|
|
|
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
|
return super.on(event, listener);
|
|
}
|
|
|
|
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
|
return super.off(event, listener);
|
|
}
|
|
}
|
|
|
|
function buildMissionRow(
|
|
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
|
): AiSessionRow {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: overrides.id,
|
|
type: overrides.type ?? "mission_interview",
|
|
status: overrides.status,
|
|
title: overrides.title ?? "Mission planning",
|
|
inputPayload:
|
|
overrides.inputPayload ??
|
|
JSON.stringify({ ip: "127.0.0.1", missionId: "mission-123", missionTitle: "Mission planning" }),
|
|
conversationHistory:
|
|
overrides.conversationHistory ??
|
|
JSON.stringify([
|
|
{
|
|
question: {
|
|
id: "q-1",
|
|
type: "text",
|
|
question: "What is your goal?",
|
|
description: "scope",
|
|
},
|
|
response: { "q-1": "Ship a dashboard" },
|
|
},
|
|
]),
|
|
currentQuestion:
|
|
overrides.currentQuestion ??
|
|
JSON.stringify({
|
|
id: "q-2",
|
|
type: "text",
|
|
question: "Any constraints?",
|
|
description: "details",
|
|
}),
|
|
result: overrides.result ?? null,
|
|
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
|
error: overrides.error ?? null,
|
|
projectId: overrides.projectId ?? null,
|
|
createdAt: overrides.createdAt ?? now,
|
|
updatedAt: overrides.updatedAt ?? now,
|
|
};
|
|
}
|
|
|
|
describe("mission-interview module", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
__resetMissionInterviewState();
|
|
mockCreateFnAgent.mockImplementation(async () => createMockAgent([createQuestionJson()]));
|
|
});
|
|
|
|
describe("session lifecycle", () => {
|
|
it("creates, retrieves, and cleans up a session", async () => {
|
|
const sessionId = await createMissionInterviewSession("127.0.0.1", "Launch platform", "/tmp/project");
|
|
|
|
const session = getMissionInterviewSession(sessionId);
|
|
expect(session).toBeDefined();
|
|
expect(session?.missionTitle).toBe("Launch platform");
|
|
|
|
cleanupMissionInterviewSession(sessionId);
|
|
expect(getMissionInterviewSession(sessionId)).toBeUndefined();
|
|
});
|
|
|
|
it("cancels a session and throws when canceling missing session", async () => {
|
|
const sessionId = await createMissionInterviewSession("127.0.0.2", "Cancel mission", "/tmp/project");
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
await cancelMissionInterviewSession(sessionId);
|
|
expect(getMissionInterviewSession(sessionId)).toBeUndefined();
|
|
|
|
await expect(cancelMissionInterviewSession(sessionId)).rejects.toBeInstanceOf(SessionNotFoundError);
|
|
});
|
|
});
|
|
|
|
describe("rate limiting", () => {
|
|
it("enforces max sessions per IP and exposes reset time", async () => {
|
|
const ip = "10.0.0.1";
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await createMissionInterviewSession(ip, `Mission ${i}`, "/tmp/project");
|
|
}
|
|
|
|
await expect(createMissionInterviewSession(ip, "Mission 6", "/tmp/project")).rejects.toBeInstanceOf(RateLimitError);
|
|
expect(getRateLimitResetTime(ip)).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("checkRateLimit tracks allowance and lockout", () => {
|
|
const ip = "10.0.0.2";
|
|
for (let i = 0; i < 5; i++) {
|
|
expect(checkRateLimit(ip)).toBe(true);
|
|
}
|
|
expect(checkRateLimit(ip)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("rehydration and session lookup", () => {
|
|
it("rehydrates mission interview sessions from recoverable rows", () => {
|
|
const store = new MockAiSessionStore();
|
|
const missionRow = buildMissionRow({ id: "mission-rehydrate-1", status: "awaiting_input" });
|
|
const planningRow = buildMissionRow({ id: "planning-rehydrate-1", status: "awaiting_input", type: "planning" });
|
|
store.rows.set(missionRow.id, missionRow);
|
|
store.rows.set(planningRow.id, planningRow);
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
expect(rehydrated).toBe(1);
|
|
const session = getMissionInterviewSession(missionRow.id);
|
|
expect(session).toBeDefined();
|
|
expect(session?.id).toBe(missionRow.id);
|
|
expect(session?.ip).toBe("127.0.0.1");
|
|
expect(session?.missionId).toBe("mission-123");
|
|
expect(session?.currentQuestion?.id).toBe("q-2");
|
|
expect(session?.agent).toBeUndefined();
|
|
expect(getMissionInterviewSession(planningRow.id)).toBeUndefined();
|
|
});
|
|
|
|
it("skips corrupted rows and continues with valid rows", () => {
|
|
const store = new MockAiSessionStore();
|
|
const goodRow = buildMissionRow({ id: "mission-good", status: "awaiting_input" });
|
|
const badRow = buildMissionRow({
|
|
id: "mission-bad",
|
|
status: "awaiting_input",
|
|
conversationHistory: "{bad-json",
|
|
});
|
|
store.rows.set(goodRow.id, goodRow);
|
|
store.rows.set(badRow.id, badRow);
|
|
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
expect(rehydrated).toBe(1);
|
|
expect(getMissionInterviewSession(goodRow.id)).toBeDefined();
|
|
expect(getMissionInterviewSession(badRow.id)).toBeUndefined();
|
|
// Assert structured diagnostic record
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "mission-interview",
|
|
message: "Failed to rehydrate session",
|
|
context: expect.objectContaining({
|
|
sessionId: badRow.id,
|
|
operation: "rehydrate",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
it("falls through to SQLite when in-memory session is missing", () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-fallthrough", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const session = getMissionInterviewSession(row.id);
|
|
|
|
expect(session).toBeDefined();
|
|
expect(session?.missionTitle).toBe("Mission planning");
|
|
expect(session?.agent).toBeUndefined();
|
|
});
|
|
|
|
it("returns in-memory session before SQLite fallback", () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-memory-first", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
rehydrateFromStore(store as any);
|
|
|
|
store.rows.set(
|
|
row.id,
|
|
buildMissionRow({
|
|
id: row.id,
|
|
status: "awaiting_input",
|
|
inputPayload: JSON.stringify({ ip: "10.0.0.5", missionId: "mission-xyz", missionTitle: "SQLite title" }),
|
|
}),
|
|
);
|
|
|
|
const getSpy = vi.spyOn(store, "get");
|
|
const session = getMissionInterviewSession(row.id);
|
|
|
|
expect(session?.missionTitle).toBe("Mission planning");
|
|
expect(getSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns undefined when session exists nowhere", () => {
|
|
const store = new MockAiSessionStore();
|
|
setAiSessionStore(store as any);
|
|
|
|
expect(getMissionInterviewSession("missing-session")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("submitMissionInterviewResponse", () => {
|
|
it("processes response and returns completed summary", async () => {
|
|
mockCreateFnAgent.mockImplementationOnce(async () =>
|
|
createMockAgent([createQuestionJson("q-plan"), createCompleteJson()]),
|
|
);
|
|
|
|
const sessionId = await createMissionInterviewSession("172.16.0.1", "Build mission", "/tmp/project");
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
const session = getMissionInterviewSession(sessionId);
|
|
const questionId = session?.currentQuestion?.id;
|
|
expect(questionId).toBe("q-plan");
|
|
|
|
const result = await submitMissionInterviewResponse(sessionId, {
|
|
[questionId as string]: "We should prioritize auth first",
|
|
});
|
|
|
|
expect(result.type).toBe("complete");
|
|
expect(getMissionInterviewSummary(sessionId)?.missionTitle).toBe("Mission Ready");
|
|
});
|
|
|
|
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-rehydrated-1", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
|
|
setAiSessionStore(store as any);
|
|
expect(rehydrateFromStore(store as any)).toBe(1);
|
|
|
|
const resumedAgent = createMockAgent([
|
|
createQuestionJson("q-context"),
|
|
JSON.stringify({
|
|
type: "question",
|
|
data: {
|
|
id: "q-3",
|
|
type: "text",
|
|
question: "What timeline do you have?",
|
|
description: "delivery",
|
|
},
|
|
}),
|
|
]);
|
|
const createFnAgentSpy = vi.fn(async () => resumedAgent);
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
const result = await submitMissionInterviewResponse(
|
|
row.id,
|
|
{ "q-2": "Need launch in 4 weeks" },
|
|
"/tmp/project",
|
|
);
|
|
|
|
expect(result.type).toBe("question");
|
|
if (result.type === "question") {
|
|
expect(result.data.id).toBe("q-3");
|
|
}
|
|
expect(createFnAgentSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: "/tmp/project",
|
|
systemPrompt: expect.stringContaining("mission planning assistant"),
|
|
}),
|
|
);
|
|
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
|
|
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
|
|
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any constraints?");
|
|
expect(getMissionInterviewSession(row.id)?.agent).toBeDefined();
|
|
});
|
|
|
|
it("throws SessionNotFoundError for unknown session", async () => {
|
|
await expect(submitMissionInterviewResponse("missing", {})).rejects.toBeInstanceOf(SessionNotFoundError);
|
|
});
|
|
|
|
it("throws InvalidSessionStateError when no active question", async () => {
|
|
const sessionId = await createMissionInterviewSession("172.16.0.2", "No question", "/tmp/project");
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
const session = getMissionInterviewSession(sessionId);
|
|
if (!session) throw new Error("session should exist");
|
|
session.currentQuestion = undefined;
|
|
|
|
await expect(submitMissionInterviewResponse(sessionId, {})).rejects.toBeInstanceOf(InvalidSessionStateError);
|
|
});
|
|
|
|
it("throws InvalidSessionStateError when rootDir is missing for a rehydrated session", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-rehydrated-2", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
rehydrateFromStore(store as any);
|
|
|
|
await expect(submitMissionInterviewResponse(row.id, { "q-2": "answer" })).rejects.toThrow(
|
|
"cannot be resumed without project context",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("retryMissionInterviewSession", () => {
|
|
it("rehydrates errored sessions and retries the last response", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({
|
|
id: "mission-retry-1",
|
|
status: "error",
|
|
error: "Transient outage",
|
|
conversationHistory: JSON.stringify([
|
|
{
|
|
question: {
|
|
id: "q-1",
|
|
type: "text",
|
|
question: "What is your goal?",
|
|
description: "scope",
|
|
},
|
|
response: { "q-1": "Ship a dashboard" },
|
|
},
|
|
]),
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await retryMissionInterviewSession(row.id, "/tmp/project");
|
|
|
|
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1);
|
|
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("What is your goal?");
|
|
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Ship a dashboard");
|
|
|
|
expect(getMissionInterviewSession(row.id)?.currentQuestion?.id).toBe("q-retry");
|
|
expect(store.get(row.id)?.status).toBe("awaiting_input");
|
|
expect(store.get(row.id)?.error).toBeNull();
|
|
});
|
|
|
|
it("replays the initial mission prompt when history is empty", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({
|
|
id: "mission-retry-2",
|
|
status: "error",
|
|
error: "First turn failed",
|
|
inputPayload: JSON.stringify({
|
|
ip: "127.0.0.1",
|
|
missionId: "mission-999",
|
|
missionTitle: "Launch alpha",
|
|
}),
|
|
conversationHistory: "[]",
|
|
currentQuestion: null,
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-first")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await retryMissionInterviewSession(row.id, "/tmp/project");
|
|
|
|
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1);
|
|
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain('I want to plan a mission: "Launch alpha"');
|
|
expect(store.get(row.id)?.status).toBe("awaiting_input");
|
|
});
|
|
|
|
it("throws when retrying a non-error mission session", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-not-error", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
await expect(retryMissionInterviewSession(row.id, "/tmp/project")).rejects.toBeInstanceOf(
|
|
InvalidSessionStateError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("stream manager", () => {
|
|
it("subscribes, broadcasts, unsubscribes, and cleans up", () => {
|
|
const callback = vi.fn();
|
|
const unsubscribe = missionInterviewStreamManager.subscribe("session-1", callback);
|
|
|
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(true);
|
|
|
|
const eventId = missionInterviewStreamManager.broadcast("session-1", { type: "thinking", data: "analyzing" });
|
|
expect(eventId).toBe(1);
|
|
expect(callback).toHaveBeenCalledWith({ type: "thinking", data: "analyzing" }, 1);
|
|
|
|
unsubscribe();
|
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
|
|
|
|
missionInterviewStreamManager.cleanupSession("session-1");
|
|
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
|
|
});
|
|
|
|
it("returns buffered events since last event id", () => {
|
|
const sessionId = "session-buffered";
|
|
|
|
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta-1" });
|
|
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta-2" });
|
|
missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
|
|
|
|
const buffered = missionInterviewStreamManager.getBufferedEvents(sessionId, 1);
|
|
expect(buffered).toHaveLength(2);
|
|
expect(buffered.map((event) => event.id)).toEqual([2, 3]);
|
|
expect(buffered[1]).toMatchObject({ event: "complete", data: "{}" });
|
|
});
|
|
|
|
it("clears buffered events on cleanup", () => {
|
|
const sessionId = "session-cleanup";
|
|
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta" });
|
|
|
|
expect(missionInterviewStreamManager.getBufferedEvents(sessionId, 0)).toHaveLength(1);
|
|
missionInterviewStreamManager.cleanupSession(sessionId);
|
|
expect(missionInterviewStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
|
|
});
|
|
|
|
it("logs error diagnostic when broadcast callback throws", () => {
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const throwingCallback = vi.fn(() => {
|
|
throw new Error("Callback failed");
|
|
});
|
|
missionInterviewStreamManager.subscribe("session-error-callback", throwingCallback);
|
|
|
|
// Should not throw, but should log the error
|
|
expect(() =>
|
|
missionInterviewStreamManager.broadcast("session-error-callback", { type: "thinking", data: "test" })
|
|
).not.toThrow();
|
|
|
|
expect(throwingCallback).toHaveBeenCalledTimes(1);
|
|
// Assert structured diagnostic record
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "mission-interview",
|
|
message: "Error broadcasting to client",
|
|
context: expect.objectContaining({
|
|
sessionId: "session-error-callback",
|
|
operation: "broadcast",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
});
|
|
|
|
describe("response parsing", () => {
|
|
it("parses direct JSON question responses", () => {
|
|
const parsed = parseMissionAgentResponse(createQuestionJson("q-direct"));
|
|
expect(parsed.type).toBe("question");
|
|
if (parsed.type === "question") {
|
|
expect(parsed.data.id).toBe("q-direct");
|
|
}
|
|
});
|
|
|
|
it("parses markdown-wrapped complete responses", () => {
|
|
const wrapped = `\n\`\`\`json\n${createCompleteJson()}\n\`\`\``;
|
|
const parsed = parseMissionAgentResponse(wrapped);
|
|
expect(parsed.type).toBe("complete");
|
|
});
|
|
|
|
it("parses embedded JSON inside prose", () => {
|
|
const text = `Here is the plan output:\n${createQuestionJson("q-embedded")}\nThanks.`;
|
|
const parsed = parseMissionAgentResponse(text);
|
|
expect(parsed.type).toBe("question");
|
|
if (parsed.type === "question") {
|
|
expect(parsed.data.id).toBe("q-embedded");
|
|
}
|
|
});
|
|
|
|
it("repairs and parses JSON with trailing commas", () => {
|
|
const malformed = '{"type":"question","data":{"id":"q-fix","type":"text","question":"Q?",},}';
|
|
const parsed = parseMissionAgentResponse(malformed);
|
|
expect(parsed.type).toBe("question");
|
|
});
|
|
|
|
it("throws on invalid response structure", () => {
|
|
expect(() =>
|
|
parseMissionAgentResponse(JSON.stringify({ type: "unknown", data: null })),
|
|
).toThrow("invalid response structure");
|
|
});
|
|
|
|
it("logs error diagnostic when no JSON candidate found before throwing", () => {
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const input = "I'm not sure what to ask about this project.";
|
|
expect(() => parseMissionAgentResponse(input)).toThrow("no valid JSON");
|
|
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "mission-interview",
|
|
message: "No JSON candidate found in agent response",
|
|
context: expect.objectContaining({
|
|
inputSnippet: expect.stringContaining("I'm not sure"),
|
|
operation: "parse-json",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
it("logs error diagnostic when repair also fails before throwing", () => {
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
// Invalid JSON that repair cannot fix
|
|
const input = '{"type":"question","data":{"id":q-1,"question":"What is this?"}';
|
|
expect(() => parseMissionAgentResponse(input)).toThrow("Failed to parse AI response");
|
|
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "mission-interview",
|
|
message: "Failed to parse agent response (repair also failed)",
|
|
context: expect.objectContaining({
|
|
inputSnippet: expect.stringContaining('{"type":"question"'),
|
|
operation: "parse-json-repair",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
it("logs error diagnostic for invalid response structure before throwing", () => {
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const input = '{"type":"unknown","data":null}';
|
|
expect(() => parseMissionAgentResponse(input)).toThrow("invalid response structure");
|
|
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "mission-interview",
|
|
message: "Invalid response structure from AI",
|
|
context: expect.objectContaining({
|
|
parsedSnippet: expect.stringContaining('"type":"unknown"'),
|
|
operation: "parse-validate",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
});
|
|
|
|
describe("custom errors", () => {
|
|
it("sets expected error names", () => {
|
|
expect(new RateLimitError("rate").name).toBe("RateLimitError");
|
|
expect(new SessionNotFoundError("missing").name).toBe("SessionNotFoundError");
|
|
expect(new InvalidSessionStateError("bad").name).toBe("InvalidSessionStateError");
|
|
});
|
|
});
|
|
|
|
describe("prompt override regression", () => {
|
|
const customPrompt = "You are a custom mission interview assistant.";
|
|
const defaultPromptStart = "You are a mission planning assistant";
|
|
|
|
it("uses default prompt when no overrides provided", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
await createMissionInterviewSession("192.168.1.1", "Test Mission", "/tmp/project");
|
|
|
|
await waitForCurrentQuestion(await createMissionInterviewSession("192.168.1.1", "Test Mission 2", "/tmp/project"));
|
|
|
|
// The first session starts asynchronously, so we need to wait
|
|
// Check the createFnAgent call was made with default prompt
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
|
});
|
|
|
|
it("uses override prompt when promptOverrides provided", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
const sessionId = await createMissionInterviewSession(
|
|
"192.168.1.2",
|
|
"Test Mission",
|
|
"/tmp/project",
|
|
{ "mission-interview-system": customPrompt },
|
|
);
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
expect(lastCall[0].systemPrompt).toBe(customPrompt);
|
|
});
|
|
|
|
it("falls back to default prompt when override is empty string", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
const sessionId = await createMissionInterviewSession(
|
|
"192.168.1.3",
|
|
"Test Mission",
|
|
"/tmp/project",
|
|
{ "mission-interview-system": "" },
|
|
);
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
|
});
|
|
|
|
it("passes prompt overrides through submitMissionInterviewResponse for rehydrated sessions", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-prompt-override-1", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
expect(rehydrateFromStore(store as any)).toBe(1);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-override")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await submitMissionInterviewResponse(
|
|
row.id,
|
|
{ "q-2": "Test response" },
|
|
"/tmp/project",
|
|
{ "mission-interview-system": customPrompt },
|
|
);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
systemPrompt: customPrompt,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes prompt overrides through retryMissionInterviewSession", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({
|
|
id: "mission-retry-prompt-override",
|
|
status: "error",
|
|
error: "Test error",
|
|
conversationHistory: JSON.stringify([
|
|
{
|
|
question: { id: "q-1", type: "text", question: "Goal?", description: "scope" },
|
|
response: { "q-1": "Build app" },
|
|
},
|
|
]),
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await retryMissionInterviewSession(
|
|
row.id,
|
|
"/tmp/project",
|
|
{ "mission-interview-system": customPrompt },
|
|
);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
systemPrompt: customPrompt,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not introduce unexpected model/provider override fields in createFnAgent", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
const sessionId = await createMissionInterviewSession(
|
|
"192.168.1.4",
|
|
"Test Mission",
|
|
"/tmp/project",
|
|
{ "mission-interview-system": customPrompt },
|
|
);
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
const agentConfig = lastCall[0];
|
|
|
|
// Verify only expected fields are present
|
|
expect(agentConfig).toHaveProperty("cwd");
|
|
expect(agentConfig).toHaveProperty("systemPrompt");
|
|
expect(agentConfig).toHaveProperty("tools");
|
|
expect(agentConfig).toHaveProperty("onThinking");
|
|
expect(agentConfig).toHaveProperty("onText");
|
|
|
|
// Verify stream callbacks use the active session id
|
|
const broadcastSpy = vi.spyOn(missionInterviewStreamManager, "broadcast").mockReturnValue(1);
|
|
agentConfig.onText("Hello");
|
|
expect(broadcastSpy).toHaveBeenCalledWith(sessionId, { type: "text", data: "Hello" });
|
|
|
|
agentConfig.onThinking("thinking...");
|
|
expect(broadcastSpy).toHaveBeenCalledWith(sessionId, { type: "thinking", data: "thinking..." });
|
|
broadcastSpy.mockRestore();
|
|
|
|
// Verify no unexpected model override fields
|
|
expect(agentConfig).not.toHaveProperty("modelProvider");
|
|
expect(agentConfig).not.toHaveProperty("modelId");
|
|
expect(agentConfig).not.toHaveProperty("provider");
|
|
expect(agentConfig).not.toHaveProperty("model");
|
|
});
|
|
|
|
it("prompt overrides do not affect other prompt keys", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
// Provide overrides for a different key only
|
|
const sessionId = await createMissionInterviewSession(
|
|
"192.168.1.5",
|
|
"Test Mission",
|
|
"/tmp/project",
|
|
{ "planning-system": "Should not affect mission interview" },
|
|
);
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
// Mission interview should use its own default prompt, not affected by planning-system override
|
|
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
|
});
|
|
|
|
it("falls back to default prompt on retry when promptOverrides is undefined", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({
|
|
id: "mission-retry-no-override",
|
|
status: "error",
|
|
error: "Test error",
|
|
conversationHistory: JSON.stringify([
|
|
{
|
|
question: { id: "q-1", type: "text", question: "Goal?", description: "scope" },
|
|
response: { "q-1": "Build app" },
|
|
},
|
|
]),
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await retryMissionInterviewSession(row.id, "/tmp/project", undefined);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
systemPrompt: expect.stringContaining("mission planning assistant"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to default prompt through submitMissionInterviewResponse for rehydrated sessions when overrides undefined", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildMissionRow({ id: "mission-submit-no-override", status: "awaiting_input" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
expect(rehydrateFromStore(store as any)).toBe(1);
|
|
|
|
const resumedAgent = createMockAgent([createQuestionJson("q-fallback")]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => resumedAgent);
|
|
|
|
await submitMissionInterviewResponse(row.id, { "q-2": "Test" }, "/tmp/project", undefined);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
systemPrompt: expect.stringContaining("mission planning assistant"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to default prompt when promptOverrides is empty object", async () => {
|
|
const mockAgent = createMockAgent([createQuestionJson()]);
|
|
mockCreateFnAgent.mockImplementationOnce(async () => mockAgent);
|
|
|
|
const sessionId = await createMissionInterviewSession(
|
|
"192.168.1.6",
|
|
"Test Mission",
|
|
"/tmp/project",
|
|
{},
|
|
);
|
|
await waitForCurrentQuestion(sessionId);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
const lastCall = mockCreateFnAgent.mock.calls[mockCreateFnAgent.mock.calls.length - 1];
|
|
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
|
});
|
|
});
|
|
});
|