Add comprehensive tests for CLI functionality and CodexLens compatibility

- Introduced tests for stale running fallback in CLI watch functionality to ensure proper handling of saved conversations.
- Added compatibility tests for CodexLens CLI to verify index initialization despite compatibility conflicts.
- Implemented tests for Smart Search MCP usage to validate default settings and path handling.
- Created tests for UV Manager to ensure Python preference handling works as expected.
- Added a detailed guide for CCW/Codex commands and skills, covering core commands, execution modes, and templates.
This commit is contained in:
catlog22
2026-03-08 17:30:39 +08:00
parent 4254eeeaa7
commit 9aa07e8d01
32 changed files with 2954 additions and 154 deletions

View File

@@ -181,7 +181,7 @@ interface OutputViewOptions {
outputType?: 'stdout' | 'stderr' | 'both';
turn?: string;
raw?: boolean;
final?: boolean; // Explicit --final (same as default, kept for compatibility)
final?: boolean; // Explicit --final (strict final result, no parsed/stdout fallback)
verbose?: boolean; // Show full metadata + raw stdout/stderr
project?: string; // Optional project path for lookup
}
@@ -470,10 +470,23 @@ async function outputAction(conversationId: string | undefined, options: OutputV
return;
}
// Default (and --final): output final result only
// Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout
const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content;
if (outputContent) {
const finalOutputContent = result.finalOutput?.content;
if (options.final) {
if (finalOutputContent !== undefined) {
console.log(finalOutputContent);
return;
}
console.error(chalk.yellow('No final agent result found in cached output.'));
console.error(chalk.gray(' Try without --final for best-effort output, or use --verbose to inspect raw stdout/stderr.'));
process.exit(1);
return;
}
// Default output: prefer strict final result, then fall back to best-effort parsed/plain output.
const outputContent = finalOutputContent ?? result.parsedOutput?.content ?? result.stdout?.content;
if (outputContent !== undefined) {
console.log(outputContent);
}
}
@@ -1351,7 +1364,7 @@ async function showAction(options: { all?: boolean }): Promise<void> {
// 1. Try to fetch active executions from dashboard
let activeExecs: Array<{
id: string; tool: string; mode: string; status: string;
prompt: string; startTime: number; isComplete?: boolean;
prompt: string; startTime: number | string | Date; isComplete?: boolean;
}> = [];
try {
@@ -1382,6 +1395,7 @@ async function showAction(options: { all?: boolean }): Promise<void> {
// 2. Get recent history from SQLite
const historyLimit = options.all ? 100 : 20;
const history = await getExecutionHistoryAsync(process.cwd(), { limit: historyLimit, recursive: true });
const historyById = new Map(history.executions.map(exec => [exec.id, exec]));
// 3. Build unified list: active first, then history (de-duped)
const seenIds = new Set<string>();
@@ -1393,16 +1407,26 @@ async function showAction(options: { all?: boolean }): Promise<void> {
// Active executions (running)
for (const exec of activeExecs) {
if (exec.status === 'running') {
const normalizedStartTime = normalizeTimestampMs(exec.startTime);
const matchingHistory = historyById.get(exec.id);
const shouldSuppressActiveRow = matchingHistory !== undefined && isSavedExecutionNewerThanActive(
normalizedStartTime,
matchingHistory.updated_at || matchingHistory.timestamp
);
if (shouldSuppressActiveRow) {
continue;
}
seenIds.add(exec.id);
const elapsed = Math.floor((Date.now() - exec.startTime) / 1000);
rows.push({
id: exec.id,
tool: exec.tool,
mode: exec.mode,
status: 'running',
prompt: (exec.prompt || '').replace(/\n/g, ' ').substring(0, 50),
time: `${elapsed}s ago`,
duration: `${elapsed}s...`,
time: normalizedStartTime !== undefined ? getTimeAgo(new Date(normalizedStartTime)) : 'unknown',
duration: normalizedStartTime !== undefined ? formatRunningDuration(Date.now() - normalizedStartTime) : 'running',
});
}
}
@@ -1513,6 +1537,18 @@ async function watchAction(watchId: string | undefined, options: { timeout?: str
}
if (exec.status === 'running') {
const savedConversation = getHistoryStore(process.cwd()).getConversation(watchId);
const shouldPreferSavedConversation = !!savedConversation && isSavedExecutionNewerThanActive(
normalizeTimestampMs((exec as { startTime?: unknown }).startTime),
savedConversation.updated_at || savedConversation.created_at
);
if (shouldPreferSavedConversation) {
process.stderr.write(chalk.gray(`\nExecution already completed (status: ${savedConversation.latest_status}).\n`));
process.stderr.write(chalk.dim(`Use: ccw cli output ${watchId}\n`));
return savedConversation.latest_status === 'success' ? 0 : 1;
}
// Still running — wait and poll again
await new Promise(r => setTimeout(r, 1000));
return poll();
@@ -1667,7 +1703,7 @@ async function detailAction(conversationId: string | undefined): Promise<void> {
* @returns {string}
*/
function getTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
@@ -1676,6 +1712,71 @@ function getTimeAgo(date: Date): string {
return date.toLocaleDateString();
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (value instanceof Date) {
const time = value.getTime();
return Number.isFinite(time) ? time : undefined;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return undefined;
const numericValue = Number(trimmed);
if (Number.isFinite(numericValue)) {
return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
}
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
}
function formatRunningDuration(elapsedMs: number): string {
const safeElapsedMs = Math.max(0, elapsedMs);
const totalSeconds = Math.floor(safeElapsedMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s...`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalSeconds < 3600) {
return seconds === 0 ? `${minutes}m...` : `${minutes}m ${seconds}s...`;
}
const hours = Math.floor(totalSeconds / 3600);
const remainingMinutes = Math.floor((totalSeconds % 3600) / 60);
if (totalSeconds < 86400) {
return remainingMinutes === 0 ? `${hours}h...` : `${hours}h ${remainingMinutes}m...`;
}
const days = Math.floor(totalSeconds / 86400);
const remainingHours = Math.floor((totalSeconds % 86400) / 3600);
return remainingHours === 0 ? `${days}d...` : `${days}d ${remainingHours}h...`;
}
function isSavedExecutionNewerThanActive(
activeStartTimeMs: number | undefined,
savedTimestamp: unknown
): boolean {
if (activeStartTimeMs === undefined) {
return false;
}
const savedTimestampMs = normalizeTimestampMs(savedTimestamp);
if (savedTimestampMs === undefined) {
return false;
}
return savedTimestampMs >= activeStartTimeMs;
}
/**ccw cli -p
* CLI command entry point
* @param {string} subcommand - Subcommand (status, exec, history, detail)