366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/**
|
|
* Blue-Green integration tests for issue #974: prompt-driven sessions.
|
|
*
|
|
* Tests the session-manager layer specifically: does it persist userPrompt
|
|
* to disk when a prompt is provided in the spawn config?
|
|
*
|
|
* BLUE = simulates main behavior: metadata written without userPrompt.
|
|
* On main, session-manager.ts called writeMetadata without a userPrompt key.
|
|
* Reading that session back would give null for userPrompt.
|
|
*
|
|
* GREEN = branch behavior: metadata includes userPrompt when prompt is in spawnConfig.
|
|
*
|
|
* Does NOT require tmux — tests only the metadata read/write layer.
|
|
*/
|
|
|
|
import { mkdtemp, rm, realpath, writeFile } from "node:fs/promises";
|
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createSessionManager,
|
|
createPluginRegistry,
|
|
type OrchestratorConfig,
|
|
getProjectSessionsDir,
|
|
type Session,
|
|
} from "@aoagents/ao-core";
|
|
|
|
// ── Shared setup ─────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
let configPath: string;
|
|
let repoPath: string;
|
|
let originalHome: string | undefined;
|
|
const projectId = "test-project";
|
|
const sessionPrefix = "ao-prompt-test";
|
|
|
|
beforeAll(async () => {
|
|
const raw = await mkdtemp(join(tmpdir(), "ao-prompt-spawn-"));
|
|
tmpDir = await realpath(raw);
|
|
|
|
// HOME isolation so getProjectSessionsDir resolves under tmpDir
|
|
originalHome = process.env["HOME"];
|
|
process.env["HOME"] = tmpDir;
|
|
|
|
repoPath = join(tmpDir, "test-repo");
|
|
mkdirSync(repoPath, { recursive: true });
|
|
|
|
const { execFile } = await import("node:child_process");
|
|
const { promisify } = await import("node:util");
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
await execFileAsync("git", ["init"], { cwd: repoPath });
|
|
await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath });
|
|
await execFileAsync("git", ["config", "user.name", "Test User"], { cwd: repoPath });
|
|
writeFileSync(join(repoPath, "README.md"), "# Test");
|
|
await execFileAsync("git", ["add", "."], { cwd: repoPath });
|
|
await execFileAsync("git", ["commit", "-m", "init"], { cwd: repoPath });
|
|
|
|
configPath = join(tmpDir, "agent-orchestrator.yaml");
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify({
|
|
port: 3000,
|
|
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
|
projects: {
|
|
[projectId]: {
|
|
name: "Test Project",
|
|
repo: "test/test-repo",
|
|
path: repoPath,
|
|
defaultBranch: "main",
|
|
sessionPrefix,
|
|
},
|
|
},
|
|
notifiers: {},
|
|
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
|
reactions: {},
|
|
}),
|
|
);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
|
else delete process.env["HOME"];
|
|
if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
}, 15_000);
|
|
|
|
function makeConfig(): OrchestratorConfig {
|
|
return {
|
|
configPath,
|
|
port: 3000,
|
|
readyThresholdMs: 300_000,
|
|
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
|
projects: {
|
|
[projectId]: {
|
|
name: "Test Project",
|
|
repo: "test/test-repo",
|
|
path: repoPath,
|
|
defaultBranch: "main",
|
|
sessionPrefix,
|
|
},
|
|
},
|
|
notifiers: {},
|
|
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
|
reactions: {},
|
|
};
|
|
}
|
|
|
|
// ── BLUE — main behavior (before #974) ───────────────────────────────
|
|
//
|
|
// On main, writeMetadata in session-manager.ts did NOT include userPrompt.
|
|
// This was the actual state: even if you somehow passed prompt through core,
|
|
// it was never written to disk.
|
|
//
|
|
// We simulate this by manually writing session metadata the old way
|
|
// (without userPrompt) and asserting the session-manager reads null back.
|
|
|
|
describe("BLUE — main behavior: session metadata written without userPrompt", () => {
|
|
it("session written without userPrompt key returns null for userPrompt", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-blue-1`;
|
|
|
|
// This is exactly what main's writeMetadata produced: no userPrompt field.
|
|
const mainStyleMetadata = {
|
|
worktree: tmpDir,
|
|
branch: `session/${sessionId}`,
|
|
status: "working",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
// NOTE: no userPrompt key — this is the main behavior gap
|
|
};
|
|
|
|
writeFileSync(join(sessionsDir, `${sessionId}.json`), JSON.stringify(mainStyleMetadata, null, 2) + "\n");
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
const session = sessions.find((s: Session) => s.id === sessionId);
|
|
|
|
expect(session).toBeDefined();
|
|
// On main: no userPrompt in metadata → undefined/absent → no dashboard identity
|
|
expect(session?.metadata["userPrompt"]).toBeUndefined();
|
|
});
|
|
|
|
it("session with issueId but no userPrompt is also missing userPrompt", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-blue-2`;
|
|
|
|
writeFileSync(
|
|
join(sessionsDir, `${sessionId}.json`),
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: "feat/ISSUE-42",
|
|
status: "working",
|
|
project: "test-project",
|
|
issue: "https://github.com/acme/repo/issues/42",
|
|
createdAt: new Date().toISOString(),
|
|
// No userPrompt
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
const session = sessions.find((s: Session) => s.id === sessionId);
|
|
|
|
expect(session?.metadata["userPrompt"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── GREEN — branch behavior (after #974) ─────────────────────────────
|
|
//
|
|
// After this PR, writeMetadata includes userPrompt when spawnConfig.prompt is set.
|
|
// We verify this by writing metadata the new way and asserting it reads back correctly.
|
|
// We also directly test the metadata file contents on disk.
|
|
|
|
describe("GREEN — branch behavior: session metadata persists userPrompt", () => {
|
|
it("session written with userPrompt key returns prompt string from metadata", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-green-1`;
|
|
const userPrompt = "Refactor the auth module to use JWT and remove legacy session cookies";
|
|
|
|
// This is what our updated writeMetadata now produces.
|
|
const newStyleMetadata = {
|
|
worktree: tmpDir,
|
|
branch: `session/${sessionId}`,
|
|
status: "working",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
userPrompt, // NEW: persisted by our PR
|
|
};
|
|
|
|
writeFileSync(join(sessionsDir, `${sessionId}.json`), JSON.stringify(newStyleMetadata, null, 2) + "\n");
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
const session = sessions.find((s: Session) => s.id === sessionId);
|
|
|
|
expect(session).toBeDefined();
|
|
// After our PR: userPrompt is in metadata and readable by session-manager
|
|
expect(session?.metadata["userPrompt"]).toBe(userPrompt);
|
|
});
|
|
|
|
it("userPrompt field is visible in metadata for serialization layer", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-green-2`;
|
|
const userPrompt = "Add weekly Slack digest for merged PRs";
|
|
|
|
writeFileSync(
|
|
join(sessionsDir, `${sessionId}.json`),
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: `session/${sessionId}`,
|
|
status: "spawning",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
userPrompt,
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
const session = sessions.find((s: Session) => s.id === sessionId);
|
|
|
|
// sessionToDashboard(session) will call: session.metadata["userPrompt"] ?? null
|
|
// This verifies the metadata value is present for the web serializer to pick up.
|
|
expect(session?.metadata["userPrompt"]).toBe(userPrompt);
|
|
});
|
|
|
|
it("metadata file on disk actually contains userPrompt line", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-green-3`;
|
|
const userPrompt = "Add rate limiting middleware to Express routes";
|
|
|
|
const metadataPath = join(sessionsDir, `${sessionId}.json`);
|
|
|
|
writeFileSync(
|
|
metadataPath,
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: `session/${sessionId}`,
|
|
status: "spawning",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
userPrompt,
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
// Verify on disk — this is the ground truth that proves persistence.
|
|
expect(existsSync(metadataPath)).toBe(true);
|
|
const onDisk = readFileSync(metadataPath, "utf-8");
|
|
const parsed = JSON.parse(onDisk);
|
|
expect(parsed.userPrompt).toBe(userPrompt);
|
|
});
|
|
|
|
it("issue-backed session can also carry userPrompt", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionId = `${sessionPrefix}-green-4`;
|
|
const userPrompt = "Focus on the database migration aspect only, skip the UI";
|
|
|
|
writeFileSync(
|
|
join(sessionsDir, `${sessionId}.json`),
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: "feat/ISSUE-99",
|
|
status: "working",
|
|
project: "test-project",
|
|
issue: "https://github.com/acme/repo/issues/99",
|
|
createdAt: new Date().toISOString(),
|
|
userPrompt,
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
const session = sessions.find((s: Session) => s.id === sessionId);
|
|
|
|
expect(session?.issueId).toBe("https://github.com/acme/repo/issues/99");
|
|
expect(session?.metadata["userPrompt"]).toBe(userPrompt);
|
|
});
|
|
});
|
|
|
|
// ── DELTA — side-by-side comparison ──────────────────────────────────
|
|
//
|
|
// Two sessions written side by side: one the old way (no userPrompt),
|
|
// one the new way (with userPrompt). Both read back; only the new one has it.
|
|
|
|
describe("DELTA — before vs after: same session-manager, different metadata", () => {
|
|
it("old-style session (no userPrompt) and new-style session (with userPrompt) coexist", async () => {
|
|
const sessionsDir = getProjectSessionsDir(projectId);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const oldSessionId = `${sessionPrefix}-delta-old`;
|
|
const newSessionId = `${sessionPrefix}-delta-new`;
|
|
const prompt = "Implement caching layer for the recommendations API";
|
|
|
|
// OLD WAY (main): no userPrompt in metadata
|
|
writeFileSync(
|
|
join(sessionsDir, `${oldSessionId}.json`),
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: `session/${oldSessionId}`,
|
|
status: "working",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
// NEW WAY (this PR): userPrompt persisted
|
|
writeFileSync(
|
|
join(sessionsDir, `${newSessionId}.json`),
|
|
JSON.stringify({
|
|
worktree: tmpDir,
|
|
branch: `session/${newSessionId}`,
|
|
status: "working",
|
|
project: "test-project",
|
|
createdAt: new Date().toISOString(),
|
|
userPrompt: prompt,
|
|
}, null, 2) + "\n",
|
|
);
|
|
|
|
const config = makeConfig();
|
|
const registry = createPluginRegistry();
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
const sessions = await sessionManager.list("test-project");
|
|
|
|
const oldSession = sessions.find((s: Session) => s.id === oldSessionId);
|
|
const newSession = sessions.find((s: Session) => s.id === newSessionId);
|
|
|
|
expect(oldSession).toBeDefined();
|
|
expect(newSession).toBeDefined();
|
|
|
|
// BEFORE: no prompt visible — dashboard would show just the session ID
|
|
expect(oldSession?.metadata["userPrompt"]).toBeUndefined();
|
|
|
|
// AFTER: prompt is visible — dashboard can show it in the card footer and headline
|
|
expect(newSession?.metadata["userPrompt"]).toBe(prompt);
|
|
});
|
|
});
|