diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd91e5..b2ef5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Cortex Code (Snowflake) transcript provider (`cortexCode`) +- Discovers sessions from `~/.snowflake/cortex/conversations/*.json` +- Filters by `working_directory` to scope to workspace +- Extracts connection name, message count, tool call count, and session type as metadata +- Status detection based on `last_updated` recency and active tool calls +- Header caching by mtime to avoid re-reading unchanged files +- Watch support via shared `createProviderWatch` +- Public entry point: `@agentprobe/core/providers/cortex-code` +- 41 new tests across schemas, discovery, transcripts, and provider integration + ## [0.1.2] - 2026-03-06 ### Fixed diff --git a/README.md b/README.md index abaf4d8..30e3eea 100644 Binary files a/README.md and b/README.md differ diff --git a/package-lock.json b/package-lock.json index 4098575..2a20199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentprobe/core", - "version": "0.1.5", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentprobe/core", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", diff --git a/package.json b/package.json index 1d40984..0a1f6ba 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,12 @@ "import": "./dist/providers/opencode/index.js", "require": "./dist/providers/opencode/index.cjs", "default": "./dist/providers/opencode/index.js" + }, + "./providers/cortex-code": { + "types": "./dist/providers/cortex-code/index.d.ts", + "import": "./dist/providers/cortex-code/index.js", + "require": "./dist/providers/cortex-code/index.cjs", + "default": "./dist/providers/cortex-code/index.js" } }, "typesVersions": { @@ -71,6 +77,9 @@ ], "providers/opencode": [ "dist/providers/opencode/index.d.ts" + ], + "providers/cortex-code": [ + "dist/providers/cortex-code/index.d.ts" ] } }, diff --git a/src/core/providers.ts b/src/core/providers.ts index ba541ef..a644797 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -6,6 +6,7 @@ export const PROVIDER_KINDS = { codex: "codex", claudeCode: "claude-code", openCode: "opencode", + cortexCode: "cortex-code", } as const; export type ProviderKind = (typeof PROVIDER_KINDS)[keyof typeof PROVIDER_KINDS]; diff --git a/src/index.ts b/src/index.ts index bf04aca..35227bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { import { createCompositeProvider } from "./core/composite"; import { claudeCode } from "./providers/claude-code"; import { codex } from "./providers/codex"; +import { cortexCode } from "./providers/cortex-code"; import { cursor } from "./providers/cursor"; import { openCode } from "./providers/opencode"; @@ -74,6 +75,10 @@ export { type CodexOptions, codex, } from "./providers/codex"; +export { + type CortexCodeOptions, + cortexCode, +} from "./providers/cortex-code"; export { type CursorOptions, cursor, @@ -88,7 +93,13 @@ export interface CreateObserverOptions extends Omit } export function createObserver(options: CreateObserverOptions): Observer { - const providers = options.providers ?? [cursor(), claudeCode(), codex(), openCode()]; + const providers = options.providers ?? [ + cursor(), + claudeCode(), + codex(), + openCode(), + cortexCode(), + ]; const provider = providers.length === 1 ? providers[0] : createCompositeProvider(providers); return createCoreObserver({ diff --git a/src/providers/cortex-code/constants.ts b/src/providers/cortex-code/constants.ts new file mode 100644 index 0000000..0be1b50 --- /dev/null +++ b/src/providers/cortex-code/constants.ts @@ -0,0 +1,7 @@ +export const CORTEX_CODE_SOURCE_KIND = "cortex-code-sessions"; +export const CORTEX_CODE_WATCH_DEBOUNCE_MS = 150; +export const CORTEX_CODE_RUNNING_WINDOW_MS = 3_000; +export const CORTEX_CODE_IDLE_WINDOW_MS = 60_000; +export const MAX_DISCOVERED_SESSION_FILES = 50; +export const CORTEX_CODE_HOME_SUBPATH = ".snowflake/cortex"; +export const AGENT_NAME_PREFIX_LENGTH = 6; diff --git a/src/providers/cortex-code/discovery.ts b/src/providers/cortex-code/discovery.ts new file mode 100644 index 0000000..d1b23c8 --- /dev/null +++ b/src/providers/cortex-code/discovery.ts @@ -0,0 +1,140 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { + collectJsonlFiles, + dedupePaths, + directoryExists, + normalizeWorkspacePath, +} from "@/providers/shared/discovery"; +import { readSourceFile } from "@/providers/shared/providers"; +import { CORTEX_CODE_HOME_SUBPATH, MAX_DISCOVERED_SESSION_FILES } from "./constants"; + +export interface SessionDiscoveryOptions { + workspacePaths: string[]; + cortexHomePath?: string; + maxFiles?: number; +} + +function resolveCortexHome(options: SessionDiscoveryOptions): string { + return options.cortexHomePath ?? path.join(homedir(), CORTEX_CODE_HOME_SUBPATH); +} + +export function resolveConversationsDirectory(options: SessionDiscoveryOptions): string { + return path.join(resolveCortexHome(options), "conversations"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function matchesWorkspace(cwd: string, normalizedPaths: readonly string[]): boolean { + const normalizedCwd = normalizeWorkspacePath(cwd); + return normalizedPaths.some((wp) => normalizedCwd === wp || normalizedCwd.startsWith(`${wp}/`)); +} + +interface ConversationHeader { + mtimeMs: number; + workingDirectory: string; + sessionId: string; +} + +const headerCache = new Map(); + +function parseConversationHeader( + contents: string, + mtimeMs: number, +): ConversationHeader | undefined { + try { + const parsed: unknown = JSON.parse(contents); + if (!isRecord(parsed)) { + return undefined; + } + const workingDirectory = parsed.working_directory; + const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : ""; + if (typeof workingDirectory !== "string" || workingDirectory.length === 0) { + return undefined; + } + return { mtimeMs, workingDirectory, sessionId }; + } catch { + return undefined; + } +} + +async function getConversationHeader( + filePath: string, + mtimeMs: number, +): Promise { + const cached = headerCache.get(filePath); + if (cached && cached.mtimeMs === mtimeMs) { + return cached; + } + + const contents = await readSourceFile(filePath); + if (contents === null) { + headerCache.delete(filePath); + return undefined; + } + + const header = parseConversationHeader(contents, mtimeMs); + if (header) { + headerCache.set(filePath, header); + } else { + headerCache.delete(filePath); + } + return header; +} + +export async function resolveSessionSourcePaths( + options: SessionDiscoveryOptions, +): Promise { + const maxFiles = options.maxFiles ?? MAX_DISCOVERED_SESSION_FILES; + const conversationsDir = resolveConversationsDirectory(options); + + if (!directoryExists(conversationsDir)) { + return []; + } + + const normalizedPaths = options.workspacePaths + .map(normalizeWorkspacePath) + .filter((p) => p.length > 0); + + if (normalizedPaths.length === 0) { + return []; + } + + const allFiles = collectJsonlFiles([conversationsDir], { + recursive: false, + extension: ".json", + }); + + const matching: { path: string; mtimeMs: number }[] = []; + + for (const file of allFiles) { + const header = await getConversationHeader(file.path, file.mtimeMs); + if (!header) { + continue; + } + if (matchesWorkspace(header.workingDirectory, normalizedPaths)) { + matching.push({ path: file.path, mtimeMs: file.mtimeMs }); + } + } + + return matching + .sort((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path)) + .slice(0, maxFiles) + .map((entry) => entry.path); +} + +export function listSessionFileNames(options: SessionDiscoveryOptions): string[] { + const conversationsDir = resolveConversationsDirectory(options); + + if (!directoryExists(conversationsDir)) { + return []; + } + + return dedupePaths( + collectJsonlFiles([conversationsDir], { recursive: false, extension: ".json" }).map( + (f) => f.path, + ), + ).sort(); +} diff --git a/src/providers/cortex-code/index.ts b/src/providers/cortex-code/index.ts new file mode 100644 index 0000000..bec378e --- /dev/null +++ b/src/providers/cortex-code/index.ts @@ -0,0 +1,29 @@ +export { + listSessionFileNames, + resolveConversationsDirectory, + resolveSessionSourcePaths, + type SessionDiscoveryOptions, +} from "./discovery"; +export { + type CortexCodeOptions, + cortexCode, +} from "./provider"; +export { + type ContentBlock, + type CortexCodeConversation, + extractUserTaskSummary, + type HistoryMessage, + parseConversation, +} from "./schemas"; +export { + type CortexCodeTranscriptSource, + type CortexCodeTranscriptSourceOptions, + type CortexCodeTranscriptSourceResult, + createCortexCodeTranscriptSource, +} from "./transcripts"; +export { + CORTEX_CODE_WATCH_DEBOUNCE_MS, + type CortexCodeWatch, + type CortexCodeWatchOptions, + createCortexCodeWatch, +} from "./watch"; diff --git a/src/providers/cortex-code/provider.ts b/src/providers/cortex-code/provider.ts new file mode 100644 index 0000000..1656241 --- /dev/null +++ b/src/providers/cortex-code/provider.ts @@ -0,0 +1,165 @@ +import { + type CanonicalSnapshot, + type DiscoveryInput, + type DiscoveryResult, + PROVIDER_KINDS, + type TranscriptProvider, + type TranscriptReadResult, +} from "@/core"; +import { arraysEqual, normalizeFromPayload } from "@/providers/shared/providers"; +import { CORTEX_CODE_SOURCE_KIND } from "./constants"; +import { + listSessionFileNames, + resolveConversationsDirectory, + resolveSessionSourcePaths, +} from "./discovery"; +import { + type CortexCodeTranscriptSource, + type CortexCodeTranscriptSourceResult, + createCortexCodeTranscriptSource, +} from "./transcripts"; +import { type CortexCodeWatchOptions, createCortexCodeWatch } from "./watch"; + +export interface CortexCodeOptions { + cortexHomePath?: string; + sourceLabel?: string; + watch?: CortexCodeWatchOptions | false; + maxFiles?: number; +} + +interface ProviderState { + source: CortexCodeTranscriptSource | undefined; + sourcePathKey: string; + connected: boolean; + discovery: DiscoveryResult | undefined; + files: string[] | undefined; + workspaces: string[] | undefined; +} + +export function cortexCode(options: CortexCodeOptions = {}): TranscriptProvider { + const sourceLabel = options.sourceLabel ?? CORTEX_CODE_SOURCE_KIND; + const cortexHomePath = options.cortexHomePath; + const maxFiles = options.maxFiles; + const watch = options.watch === false ? undefined : createCortexCodeWatch(options.watch); + const state: ProviderState = { + source: undefined, + sourcePathKey: "", + connected: false, + discovery: undefined, + files: undefined, + workspaces: undefined, + }; + + return { + id: PROVIDER_KINDS.cortexCode, + discover: (workspacePaths) => + discoverSessions(workspacePaths, state, { cortexHomePath, maxFiles }), + connect: () => { + state.connected = true; + state.source?.connect(); + }, + disconnect: () => disconnectState(state), + read: (inputs, now = Date.now()) => readSessions(inputs, now, state, sourceLabel), + normalize: (readResult): CanonicalSnapshot => normalizeFromPayload(readResult), + watch, + }; +} + +async function discoverSessions( + workspacePaths: string[], + state: ProviderState, + opts: { cortexHomePath?: string; maxFiles?: number }, +): Promise { + const discoveryOptions = { + workspacePaths, + cortexHomePath: opts.cortexHomePath, + maxFiles: opts.maxFiles, + }; + const files = listSessionFileNames(discoveryOptions); + if ( + state.discovery && + state.files && + state.workspaces && + arraysEqual(files, state.files) && + arraysEqual(workspacePaths, state.workspaces) + ) { + return state.discovery; + } + + const conversationsDir = resolveConversationsDirectory(discoveryOptions); + const sourcePaths = await resolveSessionSourcePaths(discoveryOptions); + const inputs: DiscoveryInput[] = sourcePaths.map((sourcePath) => ({ + uri: sourcePath, + kind: "file", + metadata: { providerId: PROVIDER_KINDS.cortexCode }, + })); + state.discovery = { inputs, watchPaths: [conversationsDir], warnings: [] }; + state.files = files; + state.workspaces = [...workspacePaths]; + return state.discovery; +} + +function disconnectState(state: ProviderState): void { + state.connected = false; + state.source?.disconnect(); + state.discovery = undefined; + state.files = undefined; + state.workspaces = undefined; +} + +async function readSessions( + inputs: DiscoveryInput[], + now: number, + state: ProviderState, + sourceLabel: string, +): Promise { + const sourcePaths = inputs.map((input) => input.uri); + const nextSourcePathKey = sourcePaths.join("\n"); + state.source = ensureSource( + state.source, + sourcePaths, + sourceLabel, + state.sourcePathKey, + nextSourcePathKey, + ); + state.sourcePathKey = nextSourcePathKey; + if (state.connected) { + state.source.connect(); + } + const snapshot = await state.source.readSnapshot(now); + return buildReadResult(snapshot, now); +} + +function buildReadResult( + snapshot: CortexCodeTranscriptSourceResult, + now: number, +): TranscriptReadResult { + return { + records: [ + { + provider: PROVIDER_KINDS.cortexCode, + inputUri: "cortex-code://sessions", + observedAt: now, + payload: snapshot, + }, + ], + health: { + connected: snapshot.connected, + sourceLabel: snapshot.sourceLabel, + warnings: snapshot.warnings, + }, + }; +} + +function ensureSource( + existing: CortexCodeTranscriptSource | undefined, + sourcePaths: string[], + sourceLabel: string, + previousKey: string, + nextKey: string, +): CortexCodeTranscriptSource { + if (existing && nextKey === previousKey) { + return existing; + } + return createCortexCodeTranscriptSource({ sourcePaths, sourceLabel }); +} diff --git a/src/providers/cortex-code/schemas.ts b/src/providers/cortex-code/schemas.ts new file mode 100644 index 0000000..ce4aa55 --- /dev/null +++ b/src/providers/cortex-code/schemas.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +const toolUsePayloadSchema = z.object({ + tool_use_id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()).optional(), +}); + +const toolResultPayloadSchema = z.object({ + name: z.string().optional(), + tool_use_id: z.string(), + content: z.unknown(), + status: z.string().optional(), +}); + +const contentBlockSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("text"), + text: z.string().optional(), + is_user_prompt: z.boolean().optional(), + internalOnly: z.boolean().optional(), + }), + z.object({ + type: z.literal("thinking"), + thinking: z.string().optional(), + }), + z.object({ + type: z.literal("tool_use"), + tool_use: toolUsePayloadSchema, + }), + z.object({ + type: z.literal("tool_result"), + tool_result: toolResultPayloadSchema.optional(), + name: z.string().optional(), + tool_use_id: z.string().optional(), + content: z.unknown().optional(), + status: z.string().optional(), + }), +]); +export type ContentBlock = z.infer; + +const historyMessageSchema = z.object({ + role: z.enum(["user", "assistant"]), + id: z.string().optional(), + content: z.array(contentBlockSchema), +}); +export type HistoryMessage = z.infer; + +const conversationSchema = z.object({ + session_id: z.string(), + title: z.string().optional(), + working_directory: z.string().optional(), + session_type: z.string().optional(), + created_at: z.string().optional(), + last_updated: z.string().optional(), + connection_name: z.string().optional(), + history: z.array(historyMessageSchema), +}); +export type CortexCodeConversation = z.infer; + +export function parseConversation(value: unknown): CortexCodeConversation | null { + const result = conversationSchema.safeParse(value); + return result.success ? result.data : null; +} + +export function isToolResultError(block: ContentBlock): boolean { + if (block.type !== "tool_result") { + return false; + } + const status = block.tool_result?.status ?? block.status; + return status === "error"; +} + +const MAX_SUMMARY_LENGTH = 120; +const TRUNCATED_PREFIX_LENGTH = 117; + +function isVisibleUserText(block: ContentBlock): boolean { + if (block.type !== "text") return false; + if (block.internalOnly || block.is_user_prompt === false) return false; + const text = block.text?.trim(); + return text !== undefined && text.length > 0 && !text.startsWith(""); +} + +function findFirstVisibleText(blocks: readonly ContentBlock[]): string | undefined { + const match = blocks.find(isVisibleUserText); + if (match?.type === "text") { + return match.text?.trim(); + } + return undefined; +} + +function truncateSummary(text: string): string { + return text.length > MAX_SUMMARY_LENGTH ? `${text.slice(0, TRUNCATED_PREFIX_LENGTH)}...` : text; +} + +export function extractUserTaskSummary(history: readonly HistoryMessage[]): string { + for (const message of history) { + if (message.role !== "user") continue; + const text = findFirstVisibleText(message.content); + if (text) return truncateSummary(text); + } + return ""; +} diff --git a/src/providers/cortex-code/transcripts.ts b/src/providers/cortex-code/transcripts.ts new file mode 100644 index 0000000..0d62a78 --- /dev/null +++ b/src/providers/cortex-code/transcripts.ts @@ -0,0 +1,347 @@ +import { + CANONICAL_AGENT_KIND, + CANONICAL_AGENT_STATUS, + type CanonicalAgentSnapshot, + type CanonicalAgentStatus, +} from "@/core/model"; +import { + mergeAgents, + type ProcessFileResult, + parseTimestampMs, + pruneStaleCache, + readSourceFile, + statSourceFile, +} from "@/providers/shared/providers"; +import { + AGENT_NAME_PREFIX_LENGTH, + CORTEX_CODE_IDLE_WINDOW_MS, + CORTEX_CODE_RUNNING_WINDOW_MS, + CORTEX_CODE_SOURCE_KIND, +} from "./constants"; +import { + type ContentBlock, + type CortexCodeConversation, + extractUserTaskSummary, + type HistoryMessage, + parseConversation, +} from "./schemas"; + +export interface CortexCodeTranscriptSourceResult { + agents: CanonicalAgentSnapshot[]; + connected: boolean; + sourceLabel: string; + warnings: string[]; +} + +export interface CortexCodeTranscriptSourceOptions { + sourcePaths: string[]; + sourceLabel?: string; +} + +export interface CortexCodeTranscriptSource { + readonly sourceKind: typeof CORTEX_CODE_SOURCE_KIND; + connect(): void; + disconnect(): void; + readSnapshot(now?: number): Promise; +} + +interface SessionFileCache { + mtimeMs: number; + sizeBytes: number; + agent: CanonicalAgentSnapshot; + fileUpdatedAt: number; +} + +interface SourceState { + connected: boolean; + fileCache: Map; +} + +export function createCortexCodeTranscriptSource( + options: CortexCodeTranscriptSourceOptions, +): CortexCodeTranscriptSource { + const sourcePaths = Array.isArray(options.sourcePaths) ? [...options.sourcePaths] : []; + const sourceLabel = options.sourceLabel ?? CORTEX_CODE_SOURCE_KIND; + const state: SourceState = { + connected: false, + fileCache: new Map(), + }; + + return { + sourceKind: CORTEX_CODE_SOURCE_KIND, + connect(): void { + state.connected = true; + }, + disconnect(): void { + state.connected = false; + state.fileCache.clear(); + }, + readSnapshot: (now: number = Date.now()) => + performReadSnapshot(state, sourcePaths, sourceLabel, now), + }; +} + +function performReadSnapshot( + state: SourceState, + sourcePaths: readonly string[], + sourceLabel: string, + now: number, +): Promise { + if (!state.connected) { + return Promise.resolve({ + agents: [], + connected: false, + sourceLabel, + warnings: ["Cortex Code transcript source is disconnected."], + }); + } + + if (sourcePaths.length === 0) { + return Promise.resolve({ + agents: [], + connected: false, + sourceLabel, + warnings: ["No session paths configured."], + }); + } + + return collectSourceResults(state, sourcePaths, sourceLabel, now); +} + +async function collectSourceResults( + state: SourceState, + sourcePaths: readonly string[], + sourceLabel: string, + now: number, +): Promise { + const orderedIds: string[] = []; + const latestById = new Map(); + const warnings: string[] = []; + let hasReadError = false; + let successfulReads = 0; + + for (const sourcePath of sourcePaths) { + const result = await processSourceFile(sourcePath, now, state.fileCache); + warnings.push(...result.warnings); + if (result.success) { + successfulReads += 1; + } else { + hasReadError = true; + } + mergeAgents(result.agents, orderedIds, latestById); + } + + pruneStaleCache(state.fileCache, sourcePaths); + + const agents = orderedIds + .map((id) => latestById.get(id)) + .filter((agent): agent is CanonicalAgentSnapshot => agent !== undefined); + + return { + agents, + connected: successfulReads > 0 || !hasReadError, + sourceLabel, + warnings, + }; +} + +async function processSourceFile( + sourcePath: string, + now: number, + fileCache: Map, +): Promise { + const { fileUpdatedAt, fileSizeBytes } = await statSourceFile(sourcePath, now); + const cached = fileCache.get(sourcePath); + + if (cached && cached.mtimeMs === fileUpdatedAt && cached.sizeBytes === fileSizeBytes) { + return { + agents: [recalculateStatus(cached.agent, cached.fileUpdatedAt, now)], + success: true, + warnings: [], + }; + } + + return parseAndCacheFile(sourcePath, now, fileCache, fileUpdatedAt, fileSizeBytes); +} + +function failedResult(warning: string): ProcessFileResult { + return { agents: [], success: false, warnings: [warning] }; +} + +function parseJsonConversation(contents: string): CortexCodeConversation | null { + try { + return parseConversation(JSON.parse(contents)); + } catch { + return null; + } +} + +async function parseAndCacheFile( + sourcePath: string, + now: number, + fileCache: Map, + fileUpdatedAt: number, + fileSizeBytes: number, +): Promise { + const contents = await readSourceFile(sourcePath); + if (contents === null) { + return failedResult(`Failed to read conversation: ${sourcePath}`); + } + + const conversation = parseJsonConversation(contents); + if (!conversation) { + return failedResult(`Failed to parse conversation: ${sourcePath}`); + } + + const agent = conversationToAgent(conversation, fileUpdatedAt, now); + fileCache.set(sourcePath, { + mtimeMs: fileUpdatedAt, + sizeBytes: fileSizeBytes, + agent, + fileUpdatedAt, + }); + + return { agents: [agent], success: true, warnings: [] }; +} + +function conversationToAgent( + conversation: CortexCodeConversation, + fileUpdatedAt: number, + now: number, +): CanonicalAgentSnapshot { + const sessionId = conversation.session_id; + const title = conversation.title ?? ""; + const isSubagent = conversation.session_type === "subagent"; + const taskSummary = extractUserTaskSummary(conversation.history); + + const startedAt = parseTimestampMs(conversation.created_at ?? ""); + const updatedAt = parseTimestampMs(conversation.last_updated ?? "") ?? fileUpdatedAt; + + const status = resolveStatus(conversation.history, updatedAt, now); + const metadata = extractMetadata(conversation); + + const name = + title.length > 0 && !title.startsWith("Chat for session:") + ? title + : sessionId.slice(0, AGENT_NAME_PREFIX_LENGTH); + + return { + id: sessionId, + name, + kind: CANONICAL_AGENT_KIND.local, + isSubagent, + status, + taskSummary, + startedAt, + updatedAt, + source: CORTEX_CODE_SOURCE_KIND, + metadata, + }; +} + +function resolveStatus( + history: readonly HistoryMessage[], + updatedAt: number, + now: number, +): CanonicalAgentStatus { + const elapsed = now - updatedAt; + + if (elapsed > CORTEX_CODE_IDLE_WINDOW_MS) { + return CANONICAL_AGENT_STATUS.completed; + } + + if (elapsed <= CORTEX_CODE_RUNNING_WINDOW_MS) { + const lastMessage = findLastNonEmptyMessage(history); + if (!lastMessage) { + return CANONICAL_AGENT_STATUS.running; + } + if (isActivelyWorking(lastMessage)) { + return CANONICAL_AGENT_STATUS.running; + } + } + + return CANONICAL_AGENT_STATUS.idle; +} + +function findLastNonEmptyMessage(history: readonly HistoryMessage[]): HistoryMessage | undefined { + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].content.length > 0) { + return history[i]; + } + } + return undefined; +} + +function isActivelyWorking(message: HistoryMessage): boolean { + if (message.role === "user") { + return hasToolResults(message.content); + } + return hasToolUseCalls(message.content); +} + +function hasToolResults(blocks: readonly ContentBlock[]): boolean { + return blocks.some((block) => block.type === "tool_result"); +} + +function hasToolUseCalls(blocks: readonly ContentBlock[]): boolean { + return blocks.some((block) => block.type === "tool_use"); +} + +function recalculateStatus( + agent: CanonicalAgentSnapshot, + _fileUpdatedAt: number, + now: number, +): CanonicalAgentSnapshot { + const elapsed = now - agent.updatedAt; + + if (elapsed > CORTEX_CODE_IDLE_WINDOW_MS) { + return agent.status === CANONICAL_AGENT_STATUS.completed + ? agent + : { ...agent, status: CANONICAL_AGENT_STATUS.completed }; + } + + if (elapsed > CORTEX_CODE_RUNNING_WINDOW_MS && agent.status === CANONICAL_AGENT_STATUS.running) { + return { ...agent, status: CANONICAL_AGENT_STATUS.idle }; + } + + return agent; +} + +interface ConversationMetadata extends Record { + connectionName?: string; + messageCount: number; + toolCallCount: number; + sessionType?: string; +} + +function countToolUseCalls(messages: readonly HistoryMessage[]): number { + return messages.flatMap((m) => m.content).filter((b) => b.type === "tool_use").length; +} + +function extractMetadata(conversation: CortexCodeConversation): ConversationMetadata { + let messageCount = 0; + + for (const message of conversation.history) { + if (message.role === "user" && hasNonInternalContent(message)) { + messageCount += 1; + } + } + + return { + connectionName: conversation.connection_name, + messageCount, + toolCallCount: countToolUseCalls(conversation.history), + sessionType: conversation.session_type, + }; +} + +function hasNonInternalContent(message: HistoryMessage): boolean { + return message.content.some( + (block) => + block.type === "text" && + !block.internalOnly && + block.text !== undefined && + block.text.trim().length > 0 && + !block.text.startsWith(""), + ); +} diff --git a/src/providers/cortex-code/watch.ts b/src/providers/cortex-code/watch.ts new file mode 100644 index 0000000..e1e90cb --- /dev/null +++ b/src/providers/cortex-code/watch.ts @@ -0,0 +1,22 @@ +import { + createProviderWatch, + type ProviderWatch, + type ProviderWatchOptions, +} from "@/providers/shared/watch"; +import { CORTEX_CODE_WATCH_DEBOUNCE_MS } from "./constants"; + +export { CORTEX_CODE_WATCH_DEBOUNCE_MS }; + +export type CortexCodeWatchOptions = ProviderWatchOptions; + +export type CortexCodeWatch = ProviderWatch; + +export function createCortexCodeWatch(options: CortexCodeWatchOptions = {}): CortexCodeWatch { + return createProviderWatch( + { + defaultDebounceMs: CORTEX_CODE_WATCH_DEBOUNCE_MS, + shouldEmitForFilename: (filename) => filename.endsWith(".json"), + }, + options, + ); +} diff --git a/tests/cortex-code-discovery.test.ts b/tests/cortex-code-discovery.test.ts new file mode 100644 index 0000000..f5ed99e --- /dev/null +++ b/tests/cortex-code-discovery.test.ts @@ -0,0 +1,168 @@ +import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listSessionFileNames, + resolveConversationsDirectory, + resolveSessionSourcePaths, +} from "@/providers/cortex-code/discovery"; + +describe("cortex-code discovery", () => { + const cleanupPaths: string[] = []; + + afterEach(() => { + for (const p of cleanupPaths) { + rmSync(p, { recursive: true, force: true }); + } + cleanupPaths.length = 0; + }); + + function createTempCortexHome(label: string): string { + const dir = path.join( + "/tmp", + `cortex-home-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + cleanupPaths.push(dir); + return dir; + } + + function writeConversationFile( + conversationsDir: string, + filename: string, + overrides: Record = {}, + ): string { + mkdirSync(conversationsDir, { recursive: true }); + const filePath = path.join(conversationsDir, filename); + const data = { + session_id: filename.replace(".json", ""), + title: "Test session", + working_directory: "/workspace/project-a", + session_type: "main", + created_at: "2026-03-10T07:00:00.000Z", + last_updated: "2026-03-10T07:05:00.000Z", + history: [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ], + ...overrides, + }; + writeFileSync(filePath, JSON.stringify(data), "utf8"); + return filePath; + } + + it("discovers files matching workspace working_directory", async () => { + const cortexHome = createTempCortexHome("match"); + const conversationsDir = path.join(cortexHome, "conversations"); + + const matchFile = writeConversationFile(conversationsDir, "sess-001.json", { + working_directory: "/workspace/project-a", + }); + writeConversationFile(conversationsDir, "sess-002.json", { + working_directory: "/workspace/project-b", + }); + + const paths = await resolveSessionSourcePaths({ + workspacePaths: ["/workspace/project-a"], + cortexHomePath: cortexHome, + }); + + expect(paths).toHaveLength(1); + expect(paths[0]).toBe(matchFile); + }); + + it("returns empty for non-existent conversations dir", async () => { + const cortexHome = createTempCortexHome("nodir"); + + const paths = await resolveSessionSourcePaths({ + workspacePaths: ["/workspace/project-a"], + cortexHomePath: cortexHome, + }); + + expect(paths).toEqual([]); + }); + + it("returns empty for empty workspace paths", async () => { + const cortexHome = createTempCortexHome("empty-ws"); + const conversationsDir = path.join(cortexHome, "conversations"); + writeConversationFile(conversationsDir, "sess-001.json"); + + const paths = await resolveSessionSourcePaths({ + workspacePaths: [], + cortexHomePath: cortexHome, + }); + + expect(paths).toEqual([]); + }); + + it("skips files without working_directory", async () => { + const cortexHome = createTempCortexHome("no-wd"); + const conversationsDir = path.join(cortexHome, "conversations"); + + writeConversationFile(conversationsDir, "no-wd.json", { + working_directory: undefined, + }); + const validFile = writeConversationFile(conversationsDir, "valid.json", { + working_directory: "/workspace/project-a", + }); + + const paths = await resolveSessionSourcePaths({ + workspacePaths: ["/workspace/project-a"], + cortexHomePath: cortexHome, + }); + + expect(paths).toHaveLength(1); + expect(paths[0]).toBe(validFile); + }); + + it("respects maxFiles cap", async () => { + const cortexHome = createTempCortexHome("cap"); + const conversationsDir = path.join(cortexHome, "conversations"); + + const now = Date.now() / 1000; + for (let i = 0; i < 5; i++) { + const filePath = writeConversationFile(conversationsDir, `session-${i}.json`, { + working_directory: "/workspace/capped", + }); + utimesSync(filePath, now - i, now - i); + } + + const paths = await resolveSessionSourcePaths({ + workspacePaths: ["/workspace/capped"], + cortexHomePath: cortexHome, + maxFiles: 3, + }); + + expect(paths).toHaveLength(3); + }); + + it("resolveConversationsDirectory returns correct path", () => { + const cortexHome = createTempCortexHome("dir"); + + const result = resolveConversationsDirectory({ + workspacePaths: [], + cortexHomePath: cortexHome, + }); + + expect(result).toBe(path.join(cortexHome, "conversations")); + }); + + it("listSessionFileNames returns sorted file paths", () => { + const cortexHome = createTempCortexHome("list"); + const conversationsDir = path.join(cortexHome, "conversations"); + + const fileB = writeConversationFile(conversationsDir, "beta.json"); + const fileA = writeConversationFile(conversationsDir, "alpha.json"); + + const names = listSessionFileNames({ + workspacePaths: [], + cortexHomePath: cortexHome, + }); + + expect(names).toHaveLength(2); + expect(names[0]).toBe(fileA); + expect(names[1]).toBe(fileB); + }); +}); diff --git a/tests/cortex-code-provider.test.ts b/tests/cortex-code-provider.test.ts new file mode 100644 index 0000000..30a175e --- /dev/null +++ b/tests/cortex-code-provider.test.ts @@ -0,0 +1,151 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { PROVIDER_KINDS } from "@/core/providers"; +import { cortexCode } from "@/providers/cortex-code/provider"; + +describe("cortex-code provider", () => { + const cleanupPaths: string[] = []; + + afterEach(() => { + for (const p of cleanupPaths) { + rmSync(p, { recursive: true, force: true }); + } + cleanupPaths.length = 0; + }); + + function setupCortexHome(label: string): { + cortexHome: string; + conversationsDir: string; + } { + const cortexHome = path.join( + "/tmp", + `cortex-provider-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const conversationsDir = path.join(cortexHome, "conversations"); + mkdirSync(conversationsDir, { recursive: true }); + cleanupPaths.push(cortexHome); + return { cortexHome, conversationsDir }; + } + + function writeConversationFile( + conversationsDir: string, + filename: string, + workspacePath: string, + ): string { + const filePath = path.join(conversationsDir, filename); + const data = { + session_id: filename.replace(".json", ""), + title: "Test session", + working_directory: workspacePath, + session_type: "main", + created_at: new Date().toISOString(), + last_updated: new Date().toISOString(), + connection_name: "devrel", + history: [ + { + role: "user", + content: [{ type: "text", text: "implement the cortex adapter" }], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + tool_use: { + tool_use_id: "tu-1", + name: "bash", + input: { command: "ls" }, + }, + }, + ], + }, + ], + }; + writeFileSync(filePath, JSON.stringify(data), "utf8"); + return filePath; + } + + it("discovers, reads, and normalizes into a valid CanonicalSnapshot", async () => { + const workspacePath = "/tmp/test-cortex-workspace"; + const { cortexHome, conversationsDir } = setupCortexHome("full"); + const sessionPath = writeConversationFile( + conversationsDir, + "sess-cortex-001.json", + workspacePath, + ); + + const provider = cortexCode({ cortexHomePath: cortexHome, watch: false }); + provider.connect?.(); + + const discovery = await provider.discover([workspacePath]); + expect(discovery).toEqual( + expect.objectContaining({ + inputs: expect.arrayContaining([ + expect.objectContaining({ uri: sessionPath, kind: "file" }), + ]), + watchPaths: expect.arrayContaining([path.join(cortexHome, "conversations")]), + }), + ); + + const readResult = await provider.read(discovery.inputs, Date.now()); + expect(readResult.health.connected).toBe(true); + + const normalized = await provider.normalize(readResult, Date.now()); + expect(normalized.agents.length).toBeGreaterThan(0); + + const agent = normalized.agents[0]; + expect(agent.taskSummary).toBe("implement the cortex adapter"); + expect(agent.source).toBe("cortex-code-sessions"); + expect(agent.kind).toBe("local"); + expect(agent.isSubagent).toBe(false); + expect(agent.metadata).toEqual( + expect.objectContaining({ + connectionName: "devrel", + messageCount: 1, + toolCallCount: 1, + sessionType: "main", + }), + ); + }); + + it("returns empty inputs for non-matching workspace", async () => { + const { cortexHome, conversationsDir } = setupCortexHome("nomatch"); + writeConversationFile(conversationsDir, "sess-cortex-002.json", "/some/other/project"); + + const provider = cortexCode({ cortexHomePath: cortexHome, watch: false }); + const discovery = await provider.discover(["/nonexistent/workspace"]); + expect(discovery.inputs).toHaveLength(0); + }); + + it("returns cached discovery on second call with same workspace and files", async () => { + const workspacePath = "/tmp/test-cortex-cache"; + const { cortexHome, conversationsDir } = setupCortexHome("cache"); + writeConversationFile(conversationsDir, "sess-cortex-003.json", workspacePath); + + const provider = cortexCode({ cortexHomePath: cortexHome, watch: false }); + + const first = await provider.discover([workspacePath]); + const second = await provider.discover([workspacePath]); + expect(second).toBe(first); + }); + + it("disconnect clears caches so next discover returns fresh result", async () => { + const workspacePath = "/tmp/test-cortex-disconnect"; + const { cortexHome, conversationsDir } = setupCortexHome("disconnect"); + writeConversationFile(conversationsDir, "sess-cortex-004.json", workspacePath); + + const provider = cortexCode({ cortexHomePath: cortexHome, watch: false }); + + const first = await provider.discover([workspacePath]); + provider.disconnect?.(); + const afterDisconnect = await provider.discover([workspacePath]); + expect(afterDisconnect).not.toBe(first); + expect(afterDisconnect.inputs).toHaveLength(first.inputs.length); + }); + + it("has correct provider id", () => { + const provider = cortexCode({ watch: false }); + expect(provider.id).toBe(PROVIDER_KINDS.cortexCode); + }); +}); diff --git a/tests/cortex-code-schemas.test.ts b/tests/cortex-code-schemas.test.ts new file mode 100644 index 0000000..4d6c68f --- /dev/null +++ b/tests/cortex-code-schemas.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { + type ContentBlock, + extractUserTaskSummary, + isToolResultError, + parseConversation, +} from "@/providers/cortex-code/schemas"; + +describe("cortex-code schemas", () => { + describe("parseConversation", () => { + it("returns parsed conversation for valid input", () => { + const raw = { + session_id: "abc-123", + title: "Test session", + working_directory: "/projects/foo", + session_type: "main", + created_at: "2026-03-10T07:00:00.000Z", + last_updated: "2026-03-10T07:05:00.000Z", + connection_name: "devrel", + history: [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ], + }; + + const result = parseConversation(raw); + expect(result).not.toBeNull(); + expect(result?.session_id).toBe("abc-123"); + expect(result?.working_directory).toBe("/projects/foo"); + expect(result?.connection_name).toBe("devrel"); + expect(result?.history).toHaveLength(1); + }); + + it("returns null for missing session_id", () => { + const result = parseConversation({ + history: [], + }); + expect(result).toBeNull(); + }); + + it("returns null for non-object input", () => { + expect(parseConversation("hello")).toBeNull(); + expect(parseConversation(42)).toBeNull(); + expect(parseConversation(null)).toBeNull(); + expect(parseConversation(undefined)).toBeNull(); + }); + + it("accepts minimal conversation with only session_id and history", () => { + const result = parseConversation({ + session_id: "minimal", + history: [], + }); + expect(result).not.toBeNull(); + expect(result?.session_id).toBe("minimal"); + expect(result?.history).toHaveLength(0); + }); + + it("parses all content block types", () => { + const result = parseConversation({ + session_id: "blocks", + history: [ + { + role: "assistant", + content: [ + { type: "text", text: "some output" }, + { type: "thinking", thinking: "reasoning" }, + { + type: "tool_use", + tool_use: { + tool_use_id: "tu-1", + name: "bash", + input: { command: "ls" }, + }, + }, + { + type: "tool_result", + tool_result: { + tool_use_id: "tu-1", + name: "bash", + content: "file.txt", + status: "success", + }, + }, + ], + }, + ], + }); + + expect(result).not.toBeNull(); + expect(result?.history[0].content).toHaveLength(4); + }); + }); + + describe("isToolResultError", () => { + it("returns true for tool_result with error status in nested payload", () => { + const block: ContentBlock = { + type: "tool_result", + tool_result: { + tool_use_id: "tu-1", + content: "something failed", + status: "error", + }, + }; + expect(isToolResultError(block)).toBe(true); + }); + + it("returns true for tool_result with error status at top level", () => { + const block: ContentBlock = { + type: "tool_result", + tool_use_id: "tu-1", + status: "error", + }; + expect(isToolResultError(block)).toBe(true); + }); + + it("returns false for tool_result with success status", () => { + const block: ContentBlock = { + type: "tool_result", + tool_result: { + tool_use_id: "tu-1", + content: "ok", + status: "success", + }, + }; + expect(isToolResultError(block)).toBe(false); + }); + + it("returns false for non-tool_result blocks", () => { + const block: ContentBlock = { + type: "text", + text: "hello", + }; + expect(isToolResultError(block)).toBe(false); + }); + }); + + describe("extractUserTaskSummary", () => { + it("returns first non-internal user text", () => { + const summary = extractUserTaskSummary([ + { + role: "user", + content: [{ type: "text", text: "fix the build" }], + }, + ]); + expect(summary).toBe("fix the build"); + }); + + it("skips internalOnly text blocks", () => { + const summary = extractUserTaskSummary([ + { + role: "user", + content: [ + { type: "text", text: "system stuff", internalOnly: true }, + { type: "text", text: "real task" }, + ], + }, + ]); + expect(summary).toBe("real task"); + }); + + it("skips system-reminder blocks", () => { + const summary = extractUserTaskSummary([ + { + role: "user", + content: [ + { + type: "text", + text: "reminder content", + }, + { type: "text", text: "actual question" }, + ], + }, + ]); + expect(summary).toBe("actual question"); + }); + + it("skips is_user_prompt=false blocks", () => { + const summary = extractUserTaskSummary([ + { + role: "user", + content: [ + { type: "text", text: "injected prompt", is_user_prompt: false }, + { type: "text", text: "my real question" }, + ], + }, + ]); + expect(summary).toBe("my real question"); + }); + + it("truncates long summaries to 120 chars", () => { + const longText = "a".repeat(200); + const summary = extractUserTaskSummary([ + { + role: "user", + content: [{ type: "text", text: longText }], + }, + ]); + expect(summary).toHaveLength(120); + expect(summary).toBe(`${"a".repeat(117)}...`); + }); + + it("returns empty string when no user messages exist", () => { + const summary = extractUserTaskSummary([ + { + role: "assistant", + content: [{ type: "text", text: "I can help" }], + }, + ]); + expect(summary).toBe(""); + }); + + it("returns empty string for empty history", () => { + expect(extractUserTaskSummary([])).toBe(""); + }); + }); +}); diff --git a/tests/cortex-code-transcripts.test.ts b/tests/cortex-code-transcripts.test.ts new file mode 100644 index 0000000..1d13234 --- /dev/null +++ b/tests/cortex-code-transcripts.test.ts @@ -0,0 +1,349 @@ +import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCortexCodeTranscriptSource } from "@/providers/cortex-code/transcripts"; + +describe("cortex-code transcripts", () => { + const cleanupPaths: string[] = []; + + afterEach(() => { + for (const p of cleanupPaths) { + rmSync(p, { recursive: true, force: true }); + } + cleanupPaths.length = 0; + }); + + function createTempDir(label: string): string { + const dir = path.join( + "/tmp", + `cortex-transcripts-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + cleanupPaths.push(dir); + return dir; + } + + function writeConversation( + dir: string, + filename: string, + overrides: Record = {}, + ): string { + const filePath = path.join(dir, filename); + const data = { + session_id: filename.replace(".json", ""), + title: "Test session", + working_directory: "/test", + session_type: "main", + created_at: "2026-03-10T07:00:00.000Z", + last_updated: "2026-03-10T07:05:00.000Z", + connection_name: "devrel", + history: [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "I can help with that." }], + }, + ], + ...overrides, + }; + writeFileSync(filePath, JSON.stringify(data), "utf8"); + return filePath; + } + + function setFileMtime(filePath: string, mtimeMs: number): void { + const secs = mtimeMs / 1000; + utimesSync(filePath, secs, secs); + } + + it("returns disconnected warning when not connected", async () => { + const source = createCortexCodeTranscriptSource({ sourcePaths: ["/nonexistent"] }); + const snapshot = await source.readSnapshot(); + + expect(snapshot.connected).toBe(false); + expect(snapshot.agents).toHaveLength(0); + expect(snapshot.warnings).toContain("Cortex Code transcript source is disconnected."); + }); + + it("returns empty when no source paths configured", async () => { + const source = createCortexCodeTranscriptSource({ sourcePaths: [] }); + source.connect(); + const snapshot = await source.readSnapshot(); + + expect(snapshot.connected).toBe(false); + expect(snapshot.agents).toHaveLength(0); + expect(snapshot.warnings).toContain("No session paths configured."); + }); + + it("parses session_id and metadata from conversation", async () => { + const dir = createTempDir("meta"); + const filePath = writeConversation(dir, "sess-abc.json", { + connection_name: "prod", + session_type: "main", + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents).toHaveLength(1); + const agent = snapshot.agents[0]; + expect(agent.id).toBe("sess-abc"); + expect(agent.source).toBe("cortex-code-sessions"); + expect(agent.kind).toBe("local"); + expect(agent.metadata?.connectionName).toBe("prod"); + expect(agent.metadata?.sessionType).toBe("main"); + }); + + it("counts user messages (messageCount)", async () => { + const dir = createTempDir("msgcount"); + const filePath = writeConversation(dir, "sess-count.json", { + history: [ + { + role: "user", + content: [{ type: "text", text: "first" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + { + role: "user", + content: [{ type: "text", text: "second" }], + }, + ], + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].metadata?.messageCount).toBe(2); + }); + + it("counts tool calls (toolCallCount)", async () => { + const dir = createTempDir("toolcount"); + const filePath = writeConversation(dir, "sess-tools.json", { + history: [ + { + role: "user", + content: [{ type: "text", text: "do stuff" }], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + tool_use: { tool_use_id: "tu-1", name: "bash", input: {} }, + }, + { + type: "tool_use", + tool_use: { tool_use_id: "tu-2", name: "read", input: {} }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_result: { tool_use_id: "tu-1", content: "ok" }, + }, + { + type: "tool_result", + tool_result: { tool_use_id: "tu-2", content: "ok" }, + }, + ], + }, + ], + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].metadata?.toolCallCount).toBe(2); + }); + + it("extracts task summary from first user text", async () => { + const dir = createTempDir("summary"); + const filePath = writeConversation(dir, "sess-summary.json", { + history: [ + { + role: "user", + content: [{ type: "text", text: "fix the flaky test in auth.ts" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "I'll look into it." }], + }, + ], + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].taskSummary).toBe("fix the flaky test in auth.ts"); + }); + + it("marks subagent sessions correctly", async () => { + const dir = createTempDir("subagent"); + const filePath = writeConversation(dir, "sess-sub.json", { + session_type: "subagent", + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].isSubagent).toBe(true); + }); + + it("derives status as running when recently updated with active tool use", async () => { + const dir = createTempDir("running"); + const now = Date.now(); + const filePath = writeConversation(dir, "sess-running.json", { + last_updated: new Date(now - 1000).toISOString(), + history: [ + { + role: "user", + content: [{ type: "text", text: "run tests" }], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + tool_use: { tool_use_id: "tu-1", name: "bash", input: {} }, + }, + ], + }, + ], + }); + + setFileMtime(filePath, now - 1000); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].status).toBe("running"); + }); + + it("derives status as idle when within 60s but not actively working", async () => { + const dir = createTempDir("idle"); + const now = Date.now(); + const filePath = writeConversation(dir, "sess-idle.json", { + last_updated: new Date(now - 10_000).toISOString(), + history: [ + { + role: "user", + content: [{ type: "text", text: "task" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }); + + setFileMtime(filePath, now - 10_000); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].status).toBe("idle"); + }); + + it("derives status as completed when beyond 60s since update", async () => { + const dir = createTempDir("completed"); + const filePath = writeConversation(dir, "sess-completed.json", { + history: [ + { + role: "user", + content: [{ type: "text", text: "task" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }); + + const now = Date.now(); + setFileMtime(filePath, now - 120_000); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].status).toBe("completed"); + }); + + it("uses title as agent name when available", async () => { + const dir = createTempDir("title"); + const filePath = writeConversation(dir, "sess-titled.json", { + title: "Fix auth bug", + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].name).toBe("Fix auth bug"); + }); + + it("uses session_id prefix as name when title is default format", async () => { + const dir = createTempDir("notitle"); + const filePath = writeConversation(dir, "abcdef-1234-5678.json", { + title: "Chat for session: abcdef-1234-5678", + }); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents[0].name).toBe("abcdef"); + }); + + it("handles invalid JSON gracefully with warning", async () => { + const dir = createTempDir("badjson"); + const filePath = path.join(dir, "bad.json"); + writeFileSync(filePath, "not json{{{", "utf8"); + + const now = Date.now(); + setFileMtime(filePath, now); + + const source = createCortexCodeTranscriptSource({ sourcePaths: [filePath] }); + source.connect(); + const snapshot = await source.readSnapshot(now); + + expect(snapshot.agents).toHaveLength(0); + expect(snapshot.warnings.some((w) => w.includes("Failed to parse conversation"))).toBe(true); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 12177a9..0cc897d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "providers/claude-code/index": "src/providers/claude-code/index.ts", "providers/codex/index": "src/providers/codex/index.ts", "providers/opencode/index": "src/providers/opencode/index.ts", + "providers/cortex-code/index": "src/providers/cortex-code/index.ts", }, format: ["esm", "cjs"], dts: true,