mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(agent-cases): tag agent-spawned case dirs and sweep their leftovers
A long orchestration creates one case directory per worker and deleting the sessions never removed them, so ~/codeman-cases accumulated scratch folders that were indistinguishable from real projects. They are now labelled and have a cleanup path. - src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven spawn gets a .codeman-agent-case.json marker (when, by whom, parent session, mode). Only the create branch writes it, so a linked case, a cloned repo or any pre-existing path is never labelled; reading is total, so a malformed marker means "not agent-created" rather than a half-trusted entry. - The signal is the new X-Codeman-Agent-Origin header the skill preamble sets on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body field, falling back to a resolved parentSessionId so a worker spawned by a stale skill copy is still labelled. - GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage badges each case and offers a review-then-delete sweep that names every directory in its confirm and skips any case a live session is working in. Removal stays on the existing DELETE /api/cases/:name. - Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was written per claude session and never removed (236 leftovers measured on a working machine). Now deleted with the session and swept at boot, guarded by a live-session keep set plus a 7-day age floor. Verified end to end on an isolated instance: marker written for header, body and lineage-only spawns, absent with no agent signal and for a pre-existing directory; inUse flipping on session end; badge, sticky bar, confirm and sweep driven in a browser; preamble seeded on create, removed on delete, boot sweep taking only the aged orphans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview The agent-case marker: the label that tells a scratch worker workspace
|
||||
* apart from the user's real projects.
|
||||
*
|
||||
* The rules under test are the ones that keep a cleanup affordance safe: reading is
|
||||
* total (anything that is not a well-formed version-1 marker reads as "not
|
||||
* agent-created", never as a half-trusted entry), the origin token is allowlisted
|
||||
* rather than escaped at each use, and writing never throws — a failed marker must not
|
||||
* fail the worker spawn it decorates.
|
||||
*
|
||||
* Port: N/A (pure + a temp dir).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
AGENT_CASE_MARKER_FILE,
|
||||
AGENT_ORIGIN_CODEMAN_SKILL,
|
||||
AGENT_ORIGIN_SPAWNED_BY_SESSION,
|
||||
buildAgentCaseMarker,
|
||||
normalizeAgentOrigin,
|
||||
parseAgentCaseMarker,
|
||||
readAgentCaseMarker,
|
||||
writeAgentCaseMarker,
|
||||
} from '../src/agent-case-marker.js';
|
||||
|
||||
describe('normalizeAgentOrigin', () => {
|
||||
it('accepts a short lowercase token', () => {
|
||||
expect(normalizeAgentOrigin('codeman-skill')).toBe('codeman-skill');
|
||||
expect(normalizeAgentOrigin(' Codeman-Skill ')).toBe('codeman-skill');
|
||||
expect(normalizeAgentOrigin('agent.v2_1')).toBe('agent.v2_1');
|
||||
});
|
||||
|
||||
it('drops anything that is not one', () => {
|
||||
// The value reaches a JSON file and the case-manage UI, so it is allowlisted at
|
||||
// the boundary instead of escaped at every use site.
|
||||
expect(normalizeAgentOrigin('<script>')).toBeUndefined();
|
||||
expect(normalizeAgentOrigin('has space')).toBeUndefined();
|
||||
expect(normalizeAgentOrigin('-leading-dash')).toBeUndefined();
|
||||
expect(normalizeAgentOrigin('x'.repeat(33))).toBeUndefined();
|
||||
expect(normalizeAgentOrigin('')).toBeUndefined();
|
||||
expect(normalizeAgentOrigin(undefined)).toBeUndefined();
|
||||
expect(normalizeAgentOrigin(42)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentCaseMarker', () => {
|
||||
it('keeps only the fields that were supplied', () => {
|
||||
const marker = buildAgentCaseMarker({ createdBy: AGENT_ORIGIN_CODEMAN_SKILL });
|
||||
expect(marker.version).toBe(1);
|
||||
expect(marker.createdBy).toBe(AGENT_ORIGIN_CODEMAN_SKILL);
|
||||
expect(Date.parse(marker.createdAt)).not.toBeNaN();
|
||||
expect('parentSessionId' in marker).toBe(false);
|
||||
expect('mode' in marker).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the spawned-by-session origin rather than storing junk', () => {
|
||||
expect(buildAgentCaseMarker({ createdBy: 'not a token' }).createdBy).toBe(AGENT_ORIGIN_SPAWNED_BY_SESSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAgentCaseMarker', () => {
|
||||
const valid = JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: '2026-09-07T10:00:00.000Z',
|
||||
createdBy: 'codeman-skill',
|
||||
parentSessionId: 'sess-1',
|
||||
parentSessionName: 'w1-claudeman',
|
||||
mode: 'claude',
|
||||
note: 'ignored',
|
||||
});
|
||||
|
||||
it('round-trips a well-formed marker and drops unknown fields', () => {
|
||||
const marker = parseAgentCaseMarker(valid);
|
||||
expect(marker).toEqual({
|
||||
version: 1,
|
||||
createdAt: '2026-09-07T10:00:00.000Z',
|
||||
createdBy: 'codeman-skill',
|
||||
parentSessionId: 'sess-1',
|
||||
parentSessionName: 'w1-claudeman',
|
||||
mode: 'claude',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads anything malformed as absent', () => {
|
||||
// Each of these must mean "not an agent case", because the answer drives a
|
||||
// recursive-delete affordance in the UI.
|
||||
expect(parseAgentCaseMarker('not json')).toBeNull();
|
||||
expect(parseAgentCaseMarker('[]')).toBeNull();
|
||||
expect(parseAgentCaseMarker('null')).toBeNull();
|
||||
expect(parseAgentCaseMarker(JSON.stringify({ version: 2, createdAt: '2026-09-07', createdBy: 'x' }))).toBeNull();
|
||||
expect(parseAgentCaseMarker(JSON.stringify({ version: 1, createdBy: 'x' }))).toBeNull();
|
||||
expect(parseAgentCaseMarker(JSON.stringify({ version: 1, createdAt: 'whenever', createdBy: 'x' }))).toBeNull();
|
||||
expect(
|
||||
parseAgentCaseMarker(JSON.stringify({ version: 1, createdAt: '2026-09-07T10:00:00Z', createdBy: 'bad token' }))
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeAgentCaseMarker / readAgentCaseMarker', () => {
|
||||
let caseDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
caseDir = await mkdtemp(join(tmpdir(), 'codeman-agent-case-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(caseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the case directory', async () => {
|
||||
const marker = buildAgentCaseMarker({
|
||||
createdBy: AGENT_ORIGIN_CODEMAN_SKILL,
|
||||
parentSessionId: 'sess-1',
|
||||
mode: 'claude',
|
||||
});
|
||||
expect(await writeAgentCaseMarker(caseDir, marker)).toBe(true);
|
||||
expect(await readAgentCaseMarker(caseDir)).toEqual(marker);
|
||||
});
|
||||
|
||||
it('writes a note explaining the file to whoever finds it', async () => {
|
||||
await writeAgentCaseMarker(caseDir, buildAgentCaseMarker({ createdBy: AGENT_ORIGIN_CODEMAN_SKILL }));
|
||||
const raw = JSON.parse(await readFile(join(caseDir, AGENT_CASE_MARKER_FILE), 'utf-8'));
|
||||
expect(raw.note).toContain('Delete this file');
|
||||
});
|
||||
|
||||
it('reports failure instead of throwing when the directory is missing', async () => {
|
||||
// Best-effort by design: a marker that cannot be written must not fail the spawn.
|
||||
const written = await writeAgentCaseMarker(join(caseDir, 'nope'), buildAgentCaseMarker({ createdBy: 'x-agent' }));
|
||||
expect(written).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an absent or corrupt marker as not-agent-created', async () => {
|
||||
expect(await readAgentCaseMarker(caseDir)).toBeNull();
|
||||
await writeFile(join(caseDir, AGENT_CASE_MARKER_FILE), '{ truncated', 'utf-8');
|
||||
expect(await readAgentCaseMarker(caseDir)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
@@ -21,10 +21,25 @@ import {
|
||||
removeAgentSkillFrom,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
removeAgentSessionPreamble,
|
||||
pruneAgentSessionPreambles,
|
||||
} from '../src/hooks-config.js';
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
/** Run `fn` against a throwaway XDG cache dir, restoring the env afterwards. */
|
||||
async function withCacheDir(fn: (cacheDir: string) => Promise<void>): Promise<void> {
|
||||
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||
const cacheDir = join(casePath, 'xdg-cache');
|
||||
process.env.XDG_CACHE_HOME = cacheDir;
|
||||
try {
|
||||
await fn(cacheDir);
|
||||
} finally {
|
||||
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
||||
else process.env.XDG_CACHE_HOME = prevXdg;
|
||||
}
|
||||
}
|
||||
|
||||
let casePath: string;
|
||||
const skillDir = () => join(casePath, '.claude', 'skills', 'codeman');
|
||||
|
||||
@@ -165,6 +180,57 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('removeAgentSessionPreamble drops one session cache and shrugs at a missing one', async () => {
|
||||
// Seeding writes one file per claude session and nothing used to remove them
|
||||
// (236 leftovers measured on a working machine); session teardown calls this.
|
||||
await withCacheDir(async (cacheDir) => {
|
||||
await seedAgentSessionPreamble('gone-session');
|
||||
expect(existsSync(join(cacheDir, 'codeman-agent-gone-session.sh'))).toBe(true);
|
||||
|
||||
await removeAgentSessionPreamble('gone-session');
|
||||
expect(existsSync(join(cacheDir, 'codeman-agent-gone-session.sh'))).toBe(false);
|
||||
|
||||
await expect(removeAgentSessionPreamble('never-existed')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('pruneAgentSessionPreambles takes only aged caches with no live session behind them', async () => {
|
||||
await withCacheDir(async (cacheDir) => {
|
||||
const aged = (name: string) => join(cacheDir, name);
|
||||
for (const id of ['live-old', 'dead-old', 'dead-fresh']) {
|
||||
await seedAgentSessionPreamble(id);
|
||||
}
|
||||
// Age two of them past the cutoff; `dead-fresh` stays new.
|
||||
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
await utimes(aged('codeman-agent-live-old.sh'), old, old);
|
||||
await utimes(aged('codeman-agent-dead-old.sh'), old, old);
|
||||
// An unrelated file in the same cache dir must be invisible to the sweep.
|
||||
await writeFile(aged('someone-elses-file.sh'), 'not ours\n');
|
||||
|
||||
const removed = await pruneAgentSessionPreambles(['live-old']);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(existsSync(aged('codeman-agent-dead-old.sh'))).toBe(false);
|
||||
// A live session's cache is load-bearing: the skill's two-line loader reads it
|
||||
// mid-run, so age alone must never take it.
|
||||
expect(existsSync(aged('codeman-agent-live-old.sh'))).toBe(true);
|
||||
// And a recently-seeded one belongs to a session this process may not know about.
|
||||
expect(existsSync(aged('codeman-agent-dead-fresh.sh'))).toBe(true);
|
||||
expect(existsSync(aged('someone-elses-file.sh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('pruneAgentSessionPreambles reports 0 rather than throwing when there is no cache dir', async () => {
|
||||
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||
process.env.XDG_CACHE_HOME = join(casePath, 'no-such-cache');
|
||||
try {
|
||||
expect(await pruneAgentSessionPreambles([])).toBe(0);
|
||||
} finally {
|
||||
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
||||
else process.env.XDG_CACHE_HOME = prevXdg;
|
||||
}
|
||||
});
|
||||
|
||||
it('seedAgentSessionPreamble falls back to ~/.cache when XDG_CACHE_HOME is unset', async () => {
|
||||
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||
delete process.env.XDG_CACHE_HOME;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @fileoverview End-to-end wiring of the agent-case label: quick-start writes the
|
||||
* marker, the case list publishes it, and `GET /api/cases/agent-created` reports it
|
||||
* for cleanup.
|
||||
*
|
||||
* The rules under test are the ones that decide whether the cleanup list can be
|
||||
* trusted: only a directory quick-start CREATES is ever labelled (a pre-existing
|
||||
* case — a linked repo, a real project — never is), a spawn with no agent signal at
|
||||
* all leaves no marker, the lineage header alone is enough to label one (that is how
|
||||
* a stale skill copy still gets swept up), and a case a live session is working in is
|
||||
* reported as `inUse` rather than silently offered up for deletion.
|
||||
*
|
||||
* Real filesystem against the per-file temp HOME from test/setup.ts, so the marker is
|
||||
* asserted as bytes on disk rather than through a mock.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdir, rm, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
import { getCasesDir } from '../../src/config/cases-dir.js';
|
||||
import { AGENT_CASE_MARKER_FILE } from '../../src/agent-case-marker.js';
|
||||
import type { AgentCaseSummary, CaseInfo } from '../../src/types.js';
|
||||
|
||||
const PARENT_ID = 'test-session-1'; // the id the mock context pre-populates
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
registerCaseRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('agent-created case marker', () => {
|
||||
let harness: Harness;
|
||||
const created: string[] = [];
|
||||
|
||||
/** Spawn a worker through quick-start, the skill's usual route. */
|
||||
async function quickStart(caseName: string, opts: { headers?: Record<string, string>; payload?: object } = {}) {
|
||||
created.push(caseName);
|
||||
return harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
headers: opts.headers,
|
||||
payload: { caseName, mode: 'claude', ...(opts.payload ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
const markerPath = (caseName: string) => join(getCasesDir(), caseName, AGENT_CASE_MARKER_FILE);
|
||||
|
||||
async function readMarker(caseName: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(markerPath(caseName), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listCases(): Promise<CaseInfo[]> {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
const body = JSON.parse(res.body);
|
||||
return (body.data ?? body) as CaseInfo[];
|
||||
}
|
||||
|
||||
async function listAgentCases(): Promise<AgentCaseSummary[]> {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/agent-created' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return JSON.parse(res.body).data.cases as AgentCaseSummary[];
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
for (const name of created.splice(0)) {
|
||||
await rm(join(getCasesDir(), name), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('labels a case directory created for a spawn carrying the skill origin header', async () => {
|
||||
const res = await quickStart('agentcase1', {
|
||||
headers: { 'x-codeman-agent-origin': 'codeman-skill', 'x-codeman-parent-session': PARENT_ID },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const marker = await readMarker('agentcase1');
|
||||
expect(marker).toMatchObject({
|
||||
version: 1,
|
||||
createdBy: 'codeman-skill',
|
||||
parentSessionId: PARENT_ID,
|
||||
mode: 'claude',
|
||||
});
|
||||
expect(Date.parse(String(marker?.createdAt))).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('accepts the origin as a body field too, with the body winning', async () => {
|
||||
await quickStart('agentcase2', {
|
||||
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
|
||||
payload: { agentOrigin: 'my-orchestrator' },
|
||||
});
|
||||
|
||||
expect(await readMarker('agentcase2')).toMatchObject({ createdBy: 'my-orchestrator' });
|
||||
});
|
||||
|
||||
it('labels a spawn that carries only the lineage header, which is how an older skill copy still gets swept up', async () => {
|
||||
await quickStart('agentcase3', { headers: { 'x-codeman-parent-session': PARENT_ID } });
|
||||
|
||||
expect(await readMarker('agentcase3')).toMatchObject({
|
||||
createdBy: 'agent-session',
|
||||
parentSessionId: PARENT_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes NO marker for a spawn with no agent signal at all', async () => {
|
||||
// A human clicking Run in the browser sets neither header, and their case must
|
||||
// not turn up in a cleanup list.
|
||||
await quickStart('humancase1');
|
||||
|
||||
expect(await readMarker('humancase1')).toBeNull();
|
||||
});
|
||||
|
||||
it('never labels a directory that already existed', async () => {
|
||||
// The linchpin: a linked case or a real repo is not ours to offer for deletion,
|
||||
// and the create branch is the only place the marker may be written.
|
||||
const name = 'preexisting1';
|
||||
created.push(name);
|
||||
await mkdir(join(getCasesDir(), name), { recursive: true });
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
|
||||
payload: { caseName: name, mode: 'claude' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(await readMarker(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops an unrecognised origin token rather than storing it', async () => {
|
||||
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });
|
||||
|
||||
// No parent either, so nothing labels this one at all.
|
||||
expect(await readMarker('agentcase4')).toBeNull();
|
||||
});
|
||||
|
||||
it('still spawns the worker when the origin is bogus', async () => {
|
||||
const res = await quickStart('agentcase5', { payload: { agentOrigin: 'not a token' } });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).sessionId ?? JSON.parse(res.body).data?.sessionId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('publishes the label on GET /api/cases and hides it from cases without one', async () => {
|
||||
await quickStart('agentcase6', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||
await quickStart('humancase2');
|
||||
|
||||
const cases = await listCases();
|
||||
expect(cases.find((c) => c.name === 'agentcase6')?.agentCreated).toMatchObject({ createdBy: 'codeman-skill' });
|
||||
expect(cases.find((c) => c.name === 'humancase2')?.agentCreated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists only agent cases in the cleanup listing, newest first', async () => {
|
||||
await quickStart('agentcase7', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||
await quickStart('humancase3');
|
||||
|
||||
const listed = await listAgentCases();
|
||||
expect(listed.map((c) => c.name)).toContain('agentcase7');
|
||||
expect(listed.map((c) => c.name)).not.toContain('humancase3');
|
||||
const sorted = [...listed].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
expect(listed.map((c) => c.name)).toEqual(sorted.map((c) => c.name));
|
||||
});
|
||||
|
||||
it('flags a case a live session is still working in as inUse', async () => {
|
||||
// Deleting one of these would pull the rug out from under a running worker, so
|
||||
// the UI excludes it rather than confirming it away.
|
||||
await quickStart('agentcase8', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
|
||||
const busyPath = join(getCasesDir(), 'agentcase8');
|
||||
const busy = createMockSession('busy-session') as unknown as { workingDir: string };
|
||||
busy.workingDir = busyPath;
|
||||
harness.ctx.sessions.set('busy-session', busy as never);
|
||||
|
||||
const entry = (await listAgentCases()).find((c) => c.name === 'agentcase8');
|
||||
expect(entry?.inUse).toBe(true);
|
||||
expect(entry?.path).toBe(busyPath);
|
||||
});
|
||||
|
||||
it('reports a marker-less case space as an empty list rather than failing', async () => {
|
||||
expect(await listAgentCases()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user