Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/cli/commands/execute.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {
assertValidKataName,
buildPreparedCycleOutputLines,
buildPreparedRunOutputLines,
formatDurationMs,
formatAgentLoadError,
formatExplain,
mergePinnedFlavors,
parseBetOption,
parseCompletedRunArtifacts,
parseCompletedRunTokenUsage,
parseHintFlags,
} from '@cli/commands/execute.helpers.js';

Expand Down Expand Up @@ -235,4 +242,183 @@ describe('execute helpers', () => {
expect(formatDurationMs(7_440_000)).toBe('2h 4m');
});
});

describe('parseCompletedRunArtifacts', () => {
it('returns undefined when artifacts are omitted', () => {
expect(parseCompletedRunArtifacts(undefined)).toEqual({ ok: true, value: undefined });
});

it('parses a valid artifact array', () => {
expect(parseCompletedRunArtifacts('[{"name":"report.md","path":"reports/report.md"}]')).toEqual({
ok: true,
value: [{ name: 'report.md', path: 'reports/report.md' }],
});
});

it('rejects non-array JSON payloads', () => {
expect(parseCompletedRunArtifacts('{"name":"report.md"}')).toEqual({
ok: false,
error: 'Error: --artifacts must be a JSON array',
});
});

it('rejects array items without a string name', () => {
expect(parseCompletedRunArtifacts('[null]')).toEqual({
ok: false,
error: 'Error: each artifact must have a "name" string property',
});
});

it('rejects invalid JSON', () => {
expect(parseCompletedRunArtifacts('{broken}')).toEqual({
ok: false,
error: 'Error: --artifacts must be valid JSON',
});
});
});

describe('parseCompletedRunTokenUsage', () => {
it('returns no token usage when both values are omitted', () => {
expect(parseCompletedRunTokenUsage(undefined, undefined)).toEqual({
ok: true,
value: {
hasTokens: false,
tokenUsage: undefined,
totalTokens: undefined,
},
});
});

it('sums partial token usage and defaults the missing side to zero', () => {
expect(parseCompletedRunTokenUsage(7, undefined)).toEqual({
ok: true,
value: {
hasTokens: true,
tokenUsage: { inputTokens: 7, outputTokens: undefined, total: 7 },
totalTokens: 7,
},
});
expect(parseCompletedRunTokenUsage(undefined, 3)).toEqual({
ok: true,
value: {
hasTokens: true,
tokenUsage: { inputTokens: undefined, outputTokens: 3, total: 3 },
totalTokens: 3,
},
});
});

it('preserves explicit zero counts', () => {
expect(parseCompletedRunTokenUsage(0, 0)).toEqual({
ok: true,
value: {
hasTokens: true,
tokenUsage: { inputTokens: 0, outputTokens: 0, total: 0 },
totalTokens: 0,
},
});
});

it('rejects negative or invalid values', () => {
expect(parseCompletedRunTokenUsage(-1, undefined)).toEqual({
ok: false,
error: 'Error: --input-tokens must be a non-negative integer',
});
expect(parseCompletedRunTokenUsage(undefined, Number.NaN)).toEqual({
ok: false,
error: 'Error: --output-tokens must be a non-negative integer',
});
});
});

describe('assertValidKataName', () => {
it('accepts letters, digits, hyphens, and underscores', () => {
expect(() => assertValidKataName('my_kata-1')).not.toThrow();
});

it('rejects names with traversal or path separators', () => {
expect(() => assertValidKataName('../evil')).toThrow('Invalid kata name "../evil"');
expect(() => assertValidKataName('safe/')).toThrow('Invalid kata name "safe/"');
});
});

describe('formatAgentLoadError', () => {
it('maps missing agents to the not-found guidance', () => {
expect(formatAgentLoadError(
'1234',
'Agent "1234" not found.',
)).toBe('Error: agent "1234" not found. Use "kata agent list" to see registered agents.');
});

it('preserves wrapped registry load failures without duplicating the prefix', () => {
expect(formatAgentLoadError(
'1234',
'Failed to load agent "1234": Invalid input: expected string, received undefined',
)).toBe('Error: Failed to load agent "1234": Invalid input: expected string, received undefined');
});

it('wraps raw load failures with agent context', () => {
expect(formatAgentLoadError(
'1234',
'Invalid agent ID: "1234"',
)).toBe('Error: Failed to load agent "1234": Invalid agent ID: "1234"');
});
});

describe('mergePinnedFlavors', () => {
it('returns undefined when neither flag provided any values', () => {
expect(mergePinnedFlavors(undefined, undefined)).toBeUndefined();
expect(mergePinnedFlavors([], [])).toBeUndefined();
});

it('merges primary and fallback pins in order', () => {
expect(mergePinnedFlavors(['typescript-tdd'], ['legacy-build'])).toEqual([
'typescript-tdd',
'legacy-build',
]);
});
});

describe('buildPreparedCycleOutputLines', () => {
it('renders a readable summary for each prepared cycle run', () => {
expect(buildPreparedCycleOutputLines({
cycleName: 'Dispatch Cycle',
preparedRuns: [
{
betName: 'Bet A',
runId: 'run-1',
stages: ['build', 'review'],
isolation: 'worktree',
},
],
})).toEqual([
'Prepared 1 run(s) for cycle "Dispatch Cycle"',
' Bet A',
' Run ID: run-1',
' Stages: build, review',
' Isolation: worktree',
]);
});
});

describe('buildPreparedRunOutputLines', () => {
it('renders the plain-text prepare output with the agent context block', () => {
expect(buildPreparedRunOutputLines({
betName: 'Prepared bet',
runId: 'run-1',
cycleName: 'Cycle A',
stages: ['build'],
isolation: 'worktree',
}, '**Run ID**: run-1')).toEqual([
'Prepared run for bet: "Prepared bet"',
' Run ID: run-1',
' Cycle: Cycle A',
' Stages: build',
' Isolation: worktree',
'',
'Agent context block (use "kata kiai context <run-id>" to fetch at dispatch time):',
'**Run ID**: run-1',
]);
});
});
});
160 changes: 160 additions & 0 deletions src/cli/commands/execute.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ export type ParseSuccess = z.infer<typeof parseSuccessSchema>;
export type ParseFailure = z.infer<typeof parseFailureSchema>;
export type ParseResult = z.infer<typeof parseResultSchema>;
export type ExplainMatchReport = z.infer<typeof explainMatchReportSchema>;
export type CompletedRunArtifact = { name: string; path?: string };
export type CompletedRunTokenUsage = {
hasTokens: boolean;
totalTokens?: number;
tokenUsage?: {
inputTokens?: number;
outputTokens?: number;
total: number;
};
};

export interface PreparedCycleOutput {
cycleName: string;
preparedRuns: ReadonlyArray<{
betName: string;
runId: string;
stages: readonly string[];
isolation: string;
}>;
}

export interface PreparedRunOutput {
betName: string;
runId: string;
cycleName: string;
stages: readonly string[];
isolation: string;
}

const _betOptionResultSchema = z.discriminatedUnion('ok', [
parseSuccessSchema.extend({
Expand All @@ -48,6 +76,23 @@ const _hintFlagResultSchema = z.discriminatedUnion('ok', [
type BetOptionResult = z.infer<typeof _betOptionResultSchema>;
type HintFlagResult = z.infer<typeof _hintFlagResultSchema>;

const _completedRunArtifactSchema = z.object({
name: z.string(),
path: z.string().optional(),
}).passthrough();

const _completedRunArtifactsSchema = z.array(_completedRunArtifactSchema);

const _completedRunTokenUsageSchema = z.object({
hasTokens: z.boolean(),
totalTokens: z.number().nonnegative().optional(),
tokenUsage: z.object({
inputTokens: z.number().nonnegative().optional(),
outputTokens: z.number().nonnegative().optional(),
total: z.number().nonnegative(),
}).optional(),
});

export function formatExplain(
stageCategory: string,
selectedFlavors: readonly string[],
Expand Down Expand Up @@ -158,6 +203,121 @@ export function parseHintFlags(hints: readonly string[] | undefined): HintFlagRe
return { ok: true, value: result };
}

export function parseCompletedRunArtifacts(artifactsJson: string | undefined): ParseResult {
if (artifactsJson === undefined) {
return { ok: true, value: undefined };
}

try {
const parsed = JSON.parse(artifactsJson);
const artifacts = _completedRunArtifactsSchema.safeParse(parsed);
if (!artifacts.success) {
return {
ok: false,
error: Array.isArray(parsed)
? 'Error: each artifact must have a "name" string property'
: 'Error: --artifacts must be a JSON array',
};
}

return { ok: true, value: artifacts.data as CompletedRunArtifact[] };
} catch {
return {
ok: false,
error: 'Error: --artifacts must be valid JSON',
};
}
}

export function parseCompletedRunTokenUsage(inputTokens: number | undefined, outputTokens: number | undefined): ParseResult {
if (inputTokens !== undefined && (Number.isNaN(inputTokens) || inputTokens < 0)) {
return {
ok: false,
error: 'Error: --input-tokens must be a non-negative integer',
};
}

if (outputTokens !== undefined && (Number.isNaN(outputTokens) || outputTokens < 0)) {
return {
ok: false,
error: 'Error: --output-tokens must be a non-negative integer',
};
}

const hasTokens = inputTokens !== undefined || outputTokens !== undefined;
const totalTokens = hasTokens ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined;

return {
ok: true,
value: _completedRunTokenUsageSchema.parse({
hasTokens,
totalTokens,
tokenUsage: hasTokens
? {
inputTokens,
outputTokens,
total: totalTokens,
}
: undefined,
}) as CompletedRunTokenUsage,
};
}

export function formatAgentLoadError(agentId: string, message: string): string {
const normalizedMessage = message.trim();

if (/^Agent\b.*\bnot found\.?$/i.test(normalizedMessage)) {
return `Error: agent "${agentId}" not found. Use "kata agent list" to see registered agents.`;
}

const loadFailurePrefix = `Failed to load agent "${agentId}":`;
if (normalizedMessage.startsWith(loadFailurePrefix)) {
return `Error: ${normalizedMessage}`;
}

return `Error: Failed to load agent "${agentId}": ${normalizedMessage}`;
}

export function mergePinnedFlavors(
primaryPins: readonly string[] | undefined,
fallbackPins: readonly string[] | undefined,
): string[] | undefined {
const merged = [...(primaryPins ?? []), ...(fallbackPins ?? [])];
return merged.length > 0 ? merged : undefined;
}

export function buildPreparedCycleOutputLines(result: PreparedCycleOutput): string[] {
const lines = [`Prepared ${result.preparedRuns.length} run(s) for cycle "${result.cycleName}"`];
for (const run of result.preparedRuns) {
lines.push(` ${run.betName}`);
lines.push(` Run ID: ${run.runId}`);
lines.push(` Stages: ${run.stages.join(', ')}`);
lines.push(` Isolation: ${run.isolation}`);
}
return lines;
}

export function buildPreparedRunOutputLines(result: PreparedRunOutput, agentContextBlock: string): string[] {
return [
`Prepared run for bet: "${result.betName}"`,
` Run ID: ${result.runId}`,
` Cycle: ${result.cycleName}`,
` Stages: ${result.stages.join(', ')}`,
` Isolation: ${result.isolation}`,
'',
'Agent context block (use "kata kiai context <run-id>" to fetch at dispatch time):',
agentContextBlock,
];
}

export function assertValidKataName(name: string): void {
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(
`Invalid kata name "${name}": names must contain only letters, digits, hyphens, and underscores.`,
);
}
}

export function formatDurationMs(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
Expand Down
Loading
Loading