diff --git a/cli/src/agent/contextDetails.test.ts b/cli/src/agent/contextDetails.test.ts new file mode 100644 index 0000000000..db147eca2e --- /dev/null +++ b/cli/src/agent/contextDetails.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'vitest' +import type { ContextDetails, Metadata } from '@hapi/protocol' +import { + buildClaudeContextDetails, + buildCodexContextDetails, + mergeContextDetails, + publishContextDetails +} from './contextDetails' + +describe('Claude context details', () => { + it('keeps detailed SDK init lists when no /context payload is available', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + system: { + model: 'claude-sonnet', + tools: ['Read', 'Bash'], + skills: ['find-docs', 'web-search'], + slash_commands: ['/context', '/compact'] + } + }) + + expect(details).toMatchObject({ + claude: { + systemTools: ['Read', 'Bash'], + skills: [{ name: 'find-docs' }, { name: 'web-search' }], + slashCommands: ['/context', '/compact'] + } + }) + }) + + it('keeps only displayed inventories without prompt or resource content', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + system: { + model: 'claude-sonnet', + tools: ['Read', 'Bash'], + slash_commands: ['/context', '/compact'] + }, + contextUsage: { + model: 'claude-sonnet', + total_tokens: 26_697, + raw_max_tokens: 262_144, + categories: [{ name: 'System tools', tokens: 19_740, kind: 'used' }], + memory_files: [{ path: '/workspace/AGENTS.md', type: 'Project', tokens: 2_998 }], + skills: [{ name: 'find-docs', source: 'user', tokens: 2_604 }], + agents: [{ agent_type: 'probe', source: 'flagSettings', tokens: 13 }], + mcp_tools: [{ name: 'mcp__probe__echo', server_name: 'probe' }] + } + }) + + expect(details).toMatchObject({ + provider: 'claude', + model: 'claude-sonnet', + contextWindow: 262_144, + usage: { contextTokens: 26_697 }, + claude: { + systemTools: ['Read', 'Bash'], + slashCommands: ['/context', '/compact'], + skills: [{ name: 'find-docs' }], + mcpTools: [{ name: 'mcp__probe__echo', serverName: 'probe' }] + } + }) + expect(details?.claude).not.toHaveProperty('categories') + expect(details?.claude).not.toHaveProperty('memoryFiles') + expect(details?.claude).not.toHaveProperty('agents') + expect(JSON.stringify(details)).not.toContain('prompt') + }) + + it('can seed usage from a result when /context has not been requested', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + result: { + model: 'claude-opus', + usage: { input_tokens: 4_000, output_tokens: 500, cache_read_input_tokens: 3_000, cache_creation_input_tokens: 100 }, + modelUsage: { + 'claude-opus': { contextWindow: 200_000 } + } + } + }) + + expect(details).toMatchObject({ + model: 'claude-opus', + contextWindow: 200_000, + usage: { cacheReadTokens: 3_000 } + }) + }) + + it('selects the session model instead of the first subagent model', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + model: 'claude-opus', + result: { + modelUsage: { + 'claude-haiku-subagent': { contextWindow: 200_000 }, + 'claude-opus': { contextWindow: 1_000_000 } + } + } + }) + + expect(details).toMatchObject({ + model: 'claude-opus', + contextWindow: 1_000_000 + }) + }) +}) + +describe('Codex context details', () => { + it('normalizes last and cumulative usage plus runtime inventories', () => { + const details = buildCodexContextDetails({ + updatedAt: 100, + info: { + modelContextWindow: 258_400, + contextTokens: 11_000, + last: { + inputTokens: 11_000, + cachedInputTokens: 8_000, + cacheWriteInputTokens: 200, + outputTokens: 900, + reasoningOutputTokens: 300, + totalTokens: 11_900 + }, + total: { + inputTokens: 22_000, + cachedInputTokens: 16_000, + outputTokens: 1_800, + totalTokens: 23_800 + } + }, + model: 'gpt-5.6-codex', + threadResponse: { + model: 'gpt-5.6-codex', + instructionSources: ['/home/user/AGENTS.md'], + thread: { id: 'thread-1' } + }, + threadParams: { model: 'gpt-5.6-codex' }, + slashCommands: ['clear', '/compact', 'clear'], + skills: [{ + name: 'find-docs', + description: 'Find docs', + path: '/home/user/.codex/skills/find-docs/SKILL.md', + scope: 'user', + enabled: true + }], + mcpServers: { + hapi: { + command: 'node', + args: ['mcp'], + tools: { change_title: {}, list_peers: {} } + } + } + }) + + expect(details).toMatchObject({ + provider: 'codex', + model: 'gpt-5.6-codex', + contextWindow: 258_400, + usage: { contextTokens: 11_000, cacheReadTokens: 8_000 }, + codex: { + slashCommands: ['clear', '/compact'], + skills: [{ name: 'find-docs' }], + mcpServers: [{ name: 'hapi', toolNames: ['change_title', 'list_peers'] }] + } + }) + }) + + it('normalizes the standard snake_case last token usage event', () => { + const details = buildCodexContextDetails({ + updatedAt: 100, + info: { + last_token_usage: { + input_tokens: 12_000, + cached_input_tokens: 9_000 + } + } + }) + + expect(details.usage).toEqual({ + contextTokens: 12_000, + cacheReadTokens: 9_000 + }) + }) + + it('includes configured MCP inventories alongside the injected bridge', () => { + const details = buildCodexContextDetails({ + updatedAt: 100, + mcpServers: { hapi: { command: 'hapi', args: ['mcp'], tools: { change_title: {} } } }, + mcpServerInventory: [{ + name: 'qmd', + status: 'ready', + toolNames: ['search'] + }] + }) + + expect(details.codex?.mcpServers).toEqual([ + { name: 'qmd', status: 'ready', toolNames: ['search'] }, + { name: 'hapi', toolNames: ['change_title'] } + ]) + }) +}) + +describe('mergeContextDetails', () => { + it('keeps static provider details while replacing newer usage values', () => { + const first = buildCodexContextDetails({ + updatedAt: 100, + info: { modelContextWindow: 100_000, last: { inputTokens: 10 } }, + model: 'gpt-5', + threadResponse: { modelProvider: 'openai', instructionSources: ['AGENTS.md'] }, + threadParams: { sandbox: 'workspace-write' }, + mcpServers: { hapi: { command: 'node', args: ['mcp'] } } + }) + const second = buildCodexContextDetails({ + updatedAt: 200, + info: { modelContextWindow: 100_000, last: { inputTokens: 20 } }, + model: 'gpt-5', + threadId: 'thread-1' + }) + const merged = mergeContextDetails(first, second) + + expect(merged.updatedAt).toBe(200) + expect(merged.usage?.contextTokens).toBe(20) + expect(merged.codex?.mcpServers).toEqual([{ name: 'hapi' }]) + }) + + it('compacts obsolete provider fields when updating existing metadata', () => { + const legacy = { + version: 1, + updatedAt: 100, + provider: 'codex', + codex: { + sandbox: 'workspace-write', + instructionSources: ['AGENTS.md'], + skills: [{ + name: 'find-docs', + scope: 'user', + path: '/home/user/.codex/skills/find-docs/SKILL.md', + description: 'Find docs' + }] + } + } as unknown as ContextDetails + const next = buildCodexContextDetails({ + updatedAt: 200, + model: 'gpt-5', + slashCommands: ['clear'] + }) + + const compacted = mergeContextDetails(legacy, next) + + expect(compacted.codex?.skills).toEqual([{ name: 'find-docs' }]) + expect(JSON.stringify(compacted)).not.toContain('workspace-write') + expect(JSON.stringify(compacted)).not.toContain('AGENTS.md') + expect(JSON.stringify(compacted)).not.toContain('Find docs') + }) + + it('allows Codex refreshes to clear authoritative empty inventories', () => { + const first = buildCodexContextDetails({ + updatedAt: 100, + slashCommands: ['/compact'], + skills: [{ + name: 'find-docs', + description: 'Find docs', + path: '/home/user/.codex/skills/find-docs/SKILL.md', + scope: 'user', + enabled: true + }], + mcpServers: { hapi: { command: 'node', args: ['mcp'], tools: { echo: {} } } } + }) + const second = buildCodexContextDetails({ + updatedAt: 200, + slashCommands: [], + skills: [], + mcpServers: {} + }) + + const merged = mergeContextDetails(first, second) + + expect(merged.codex).toEqual({ slashCommands: [], skills: [], mcpServers: [] }) + }) + + it('allows Claude refreshes to clear authoritative empty inventories', () => { + const first = buildClaudeContextDetails({ + updatedAt: 100, + model: 'claude-opus', + system: { + tools: ['Read'], + skills: ['find-docs'], + slash_commands: ['/compact'] + }, + contextUsage: { + mcp_tools: [{ name: 'mcp__qmd__search', server_name: 'qmd' }] + } + })! + const second = buildClaudeContextDetails({ + updatedAt: 200, + model: 'claude-opus', + system: { + tools: [], + skills: [], + slash_commands: [] + }, + contextUsage: { mcp_tools: [] } + })! + + const merged = mergeContextDetails(first, second) + + expect(merged.claude).toEqual({ + skills: [], + mcpTools: [], + systemTools: [], + slashCommands: [] + }) + }) + + it('merges from the metadata value when queued publishers are applied later', () => { + const updates: Array<(metadata: Metadata) => Metadata> = [] + const client = { + updateMetadata: (handler: (metadata: Metadata) => Metadata) => { + updates.push(handler) + } + } + + publishContextDetails(client, buildClaudeContextDetails({ + updatedAt: 100, + model: 'claude-opus', + system: { tools: ['Read'] } + })!) + publishContextDetails(client, buildClaudeContextDetails({ + updatedAt: 200, + model: 'claude-opus', + messageUsage: { contextTokens: 12_000 } + })!) + + let metadata = {} as Metadata + for (const update of updates) { + metadata = update(metadata) + } + + expect(metadata.contextDetails).toMatchObject({ + usage: { contextTokens: 12_000 }, + claude: { systemTools: ['Read'] } + }) + }) +}) diff --git a/cli/src/agent/contextDetails.ts b/cli/src/agent/contextDetails.ts new file mode 100644 index 0000000000..7e1b7c1f67 --- /dev/null +++ b/cli/src/agent/contextDetails.ts @@ -0,0 +1,357 @@ +import type { + ClaudeContextDetails, + ContextDetails, + ContextUsageSnapshot, + CodexContextDetails, + Metadata +} from '@hapi/protocol' +import type { SkillMetadata, ThreadStartParams } from '@/codex/appServerTypes' +import type { McpServersConfig } from '@/codex/utils/buildHapiMcpBridge' + +type JsonRecord = Record + +export interface CodexMcpServerInventory { + name: string + toolNames?: string[] + status?: string +} + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function asTokenCount(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined + return Math.round(value) +} + +function asStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const values = value + .map(asString) + .filter((value): value is string => value !== undefined) + return values +} + +function normalizeUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined { + const record = asRecord(value) + if (!record) return undefined + + const usage: ContextUsageSnapshot = { + contextTokens: asTokenCount(record.contextTokens ?? record.context_tokens), + cacheReadTokens: asTokenCount( + record.cachedInputTokens + ?? record.cached_input_tokens + ?? record.cacheReadInputTokens + ?? record.cache_read_input_tokens + ), + } + + const hasValue = Object.values(usage).some((value) => value !== undefined) + return hasValue ? usage : undefined +} + +function normalizeCodexUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined { + const record = asRecord(value) + if (!record) return undefined + return normalizeUsageSnapshot({ + contextTokens: record.contextTokens + ?? record.context_tokens + ?? record.inputTokens + ?? record.input_tokens, + cachedInputTokens: record.cachedInputTokens ?? record.cached_input_tokens, + cacheReadInputTokens: record.cacheReadInputTokens ?? record.cache_read_input_tokens + }) +} + +function getClaudeModelUsageContextWindow(result: JsonRecord | null, model?: string): number | undefined { + const modelUsage = asRecord(result?.modelUsage ?? result?.model_usage) + if (!modelUsage) return undefined + const entries = Object.entries(modelUsage) + const selectedUsage = model + ? asRecord(modelUsage[model]) + : entries.length === 1 + ? asRecord(entries[0][1]) + : null + return asTokenCount(selectedUsage?.contextWindow ?? selectedUsage?.context_window) +} + +function buildClaudeSkills(value: unknown): ClaudeContextDetails['skills'] { + if (!Array.isArray(value)) return undefined + const skills = value.flatMap((item) => { + const record = asRecord(item) + const name = typeof item === 'string' ? asString(item) : asString(record?.name) + if (!name) return [] + return [{ name }] + }) + return skills +} + +function buildClaudeMcpTools(value: unknown): ClaudeContextDetails['mcpTools'] { + if (!Array.isArray(value)) return undefined + const tools = value.flatMap((item) => { + const record = asRecord(item) + const name = asString(record?.name ?? record?.tool_name ?? record?.toolName) + if (!name) return [] + return [{ + name, + serverName: asString(record?.server_name ?? record?.serverName ?? record?.server) + }] + }) + return tools +} + +export function buildClaudeContextDetails(args: { + contextUsage?: unknown + system?: unknown + result?: unknown + messageUsage?: unknown + model?: string | null + updatedAt?: number +}): ContextDetails | null { + const contextUsage = asRecord(args.contextUsage) + const system = asRecord(args.system) + const result = asRecord(args.result) + const model = asString(contextUsage?.model) ?? asString(args.model) ?? asString(system?.model) ?? asString(result?.model) + const contextWindow = asTokenCount( + contextUsage?.raw_max_tokens + ?? contextUsage?.rawMaxTokens + ?? contextUsage?.context_window + ?? contextUsage?.contextWindow + ?? getClaudeModelUsageContextWindow(result, model) + ) + const messageUsage = normalizeUsageSnapshot(args.messageUsage ?? result?.usage) + const contextTokens = asTokenCount( + contextUsage?.total_tokens + ?? contextUsage?.totalTokens + ?? contextUsage?.context_tokens + ?? contextUsage?.contextTokens + ) + const usage: ContextUsageSnapshot = { + ...messageUsage, + ...(contextTokens !== undefined ? { contextTokens } : {}) + } + const hasUsage = Object.values(usage).some((value) => value !== undefined) + + const skills = buildClaudeSkills(contextUsage?.skills) ?? buildClaudeSkills(system?.skills) + const mcpTools = buildClaudeMcpTools(contextUsage?.mcp_tools ?? contextUsage?.mcpTools) + const systemTools = asStringList(system?.tools) + const slashCommands = asStringList(system?.slash_commands) + const claude: ClaudeContextDetails = { + ...(skills ? { skills } : {}), + ...(mcpTools ? { mcpTools } : {}), + ...(systemTools ? { systemTools } : {}), + ...(slashCommands ? { slashCommands } : {}) + } + const hasClaudeDetails = Object.keys(claude).length > 0 + + if (!model && contextWindow === undefined && !hasUsage && !hasClaudeDetails) return null + + return { + version: 1, + updatedAt: args.updatedAt ?? Date.now(), + provider: 'claude', + ...(model ? { model } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(hasUsage ? { usage } : {}), + ...(hasClaudeDetails ? { claude } : {}) + } +} + +function buildCodexUsage(value: unknown, fallbackContextTokens?: number): ContextUsageSnapshot | undefined { + const usage = normalizeCodexUsageSnapshot(value) + if (!usage) return undefined + if (usage.contextTokens === undefined && fallbackContextTokens !== undefined) { + return { ...usage, contextTokens: fallbackContextTokens } + } + return usage +} + +function getCodexThreadRecord(response: unknown): JsonRecord | null { + return asRecord(asRecord(response)?.thread) +} + +export function buildCodexContextDetails(args: { + info?: unknown + model?: string | null + threadId?: string | null + threadResponse?: unknown + threadParams?: ThreadStartParams + slashCommands?: readonly string[] + skills?: readonly (Pick | SkillMetadata)[] + mcpServers?: McpServersConfig + mcpServerInventory?: readonly CodexMcpServerInventory[] + updatedAt?: number +}): ContextDetails { + const info = asRecord(args.info) + const last = asRecord(info?.last ?? info?.lastTokenUsage ?? info?.last_token_usage) + const infoContextTokens = asTokenCount(info?.contextTokens ?? info?.context_tokens) + const usage = buildCodexUsage(last ?? info, infoContextTokens) + const response = asRecord(args.threadResponse) + const thread = getCodexThreadRecord(args.threadResponse) + const contextWindow = asTokenCount( + info?.modelContextWindow + ?? info?.model_context_window + ?? response?.modelContextWindow + ?? response?.model_context_window + ?? thread?.modelContextWindow + ?? thread?.model_context_window + ) + const model = asString(args.model) ?? asString(response?.model) ?? asString(thread?.model) + const slashCommands = args.slashCommands === undefined + ? undefined + : Array.from(new Set(args.slashCommands + .filter((command) => command.trim()) + .map((command) => command.trim()))) + const skills = args.skills === undefined + ? undefined + : args.skills + .filter((skill) => skill.enabled) + .map((skill) => ({ + name: skill.name + })) + const mcpServerByName = new Map() + for (const server of args.mcpServerInventory ?? []) { + const name = server.name.trim() + if (!name) continue + mcpServerByName.set(name, { + name, + ...(server.toolNames ? { toolNames: [...server.toolNames] } : {}), + ...(server.status ? { status: server.status } : {}) + }) + } + for (const [name, server] of Object.entries(args.mcpServers ?? {})) { + const previous = mcpServerByName.get(name) + mcpServerByName.set(name, { + name, + ...(server.tools + ? { toolNames: Object.keys(server.tools) } + : previous?.toolNames + ? { toolNames: previous.toolNames } + : {}), + ...(previous?.status ? { status: previous.status } : {}) + }) + } + const mcpServers = args.mcpServers !== undefined || args.mcpServerInventory !== undefined + ? Array.from(mcpServerByName.values()) + : undefined + const codex: CodexContextDetails = { + ...(slashCommands !== undefined ? { slashCommands } : {}), + ...(skills !== undefined ? { skills } : {}), + ...(mcpServers !== undefined ? { mcpServers } : {}) + } + + return { + version: 1, + updatedAt: args.updatedAt ?? Date.now(), + provider: 'codex', + ...(model ? { model } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(usage ? { usage } : {}), + ...(Object.keys(codex).length > 0 ? { codex } : {}) + } +} + +function withoutUpdatedAt(details: ContextDetails): Omit { + const { updatedAt: _updatedAt, ...rest } = details + return rest +} + +function compactContextDetails(details: ContextDetails): ContextDetails { + const claude = details.claude + ? { + ...(claudeSkills(details.claude.skills) ? { skills: claudeSkills(details.claude.skills) } : {}), + ...(details.claude.mcpTools + ? { + mcpTools: details.claude.mcpTools.map((tool) => ({ + name: tool.name, + ...(tool.serverName ? { serverName: tool.serverName } : {}) + })) + } + : {}), + ...(details.claude.systemTools ? { systemTools: [...details.claude.systemTools] } : {}), + ...(details.claude.slashCommands ? { slashCommands: [...details.claude.slashCommands] } : {}) + } + : undefined + const codex = details.codex + ? { + ...(details.codex.slashCommands ? { slashCommands: [...details.codex.slashCommands] } : {}), + ...(details.codex.skills + ? { skills: details.codex.skills.map((skill) => ({ name: skill.name })) } + : {}), + ...(details.codex.mcpServers + ? { + mcpServers: details.codex.mcpServers.map((server) => ({ + name: server.name, + ...(server.toolNames ? { toolNames: [...server.toolNames] } : {}), + ...(server.status ? { status: server.status } : {}) + })) + } + : {}) + } + : undefined + + return { + version: 1, + updatedAt: details.updatedAt, + provider: details.provider, + ...(details.model ? { model: details.model } : {}), + ...(details.contextWindow !== undefined ? { contextWindow: details.contextWindow } : {}), + ...(details.usage ? { usage: { ...details.usage } } : {}), + ...(claude && Object.keys(claude).length > 0 ? { claude } : {}), + ...(codex && Object.keys(codex).length > 0 ? { codex } : {}) + } +} + +function claudeSkills(value: ClaudeContextDetails['skills']): ClaudeContextDetails['skills'] { + return value?.map((skill) => ({ name: skill.name })) +} + +export function mergeContextDetails( + previous: ContextDetails | null | undefined, + next: ContextDetails +): ContextDetails { + const compactPrevious = previous ? compactContextDetails(previous) : null + const compactNext = compactContextDetails(next) + if (!previous || !compactPrevious || compactPrevious.provider !== compactNext.provider) return compactNext + + const merged: ContextDetails = { + ...compactPrevious, + ...compactNext, + ...(compactPrevious.usage || compactNext.usage ? { usage: { ...compactPrevious.usage, ...compactNext.usage } } : {}), + ...(compactPrevious.claude || compactNext.claude ? { claude: { ...compactPrevious.claude, ...compactNext.claude } } : {}), + ...(compactPrevious.codex || compactNext.codex ? { codex: { ...compactPrevious.codex, ...compactNext.codex } } : {}) + } + const unchanged = JSON.stringify(withoutUpdatedAt(compactPrevious)) === JSON.stringify(withoutUpdatedAt(merged)) + const previousWasCompact = JSON.stringify(withoutUpdatedAt(previous)) === JSON.stringify(withoutUpdatedAt(compactPrevious)) + return unchanged && previousWasCompact + ? previous + : unchanged + ? compactPrevious + : merged +} + +export interface ContextDetailsClient { + getMetadata?: () => Readonly | null + updateMetadata?: (handler: (metadata: Metadata) => Metadata) => void +} + +export function publishContextDetails(client: ContextDetailsClient, next: ContextDetails): void { + if (!client.updateMetadata) return + client.updateMetadata((metadata) => { + const current = metadata.contextDetails + const merged = mergeContextDetails(current, next) + return merged === current + ? metadata + : { + ...metadata, + contextDetails: merged + } + }) +} diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index db4bf6c198..7b6f3d6f9c 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -4,14 +4,33 @@ import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher"; import { applySessionTitleFallback } from './utils/sessionTitleFallback'; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { + let lastSystemModel = session.getModel(); // Create scanner const scanner = await createSessionScanner({ sessionId: session.sessionId, workingDirectory: session.path, onMessage: (message) => { + const rawMessage = message as unknown as Record; + if (rawMessage.type === 'system' && typeof rawMessage.model === 'string') { + lastSystemModel = rawMessage.model; + } + if (rawMessage.type === 'assistant' || rawMessage.type === 'system' || rawMessage.type === 'result') { + const details = buildClaudeContextDetails({ + contextUsage: rawMessage.context_usage, + system: rawMessage.type === 'system' ? rawMessage : undefined, + result: rawMessage.type === 'result' ? rawMessage : undefined, + messageUsage: (rawMessage.message as Record | undefined)?.usage ?? rawMessage.usage, + model: typeof rawMessage.model === 'string' ? rawMessage.model : lastSystemModel + }); + if (details) { + publishContextDetails(session.client, details); + } + } + // Preserve the AI-generated title emitted by Claude Code's native // interactive CLI. It is metadata, not a visible chat message. if (message.type === 'ai-title') { diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 4a99e556b4..93ffe2af2e 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -4,7 +4,7 @@ import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; -import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; +import { SDKAssistantMessage, SDKMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; @@ -18,6 +18,7 @@ import { type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; interface PermissionsField { date: number; @@ -159,8 +160,42 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let planModeToolCalls = new Set(); let ongoingToolCalls = new Map(); + let lastSystemModel = session.getModel(); function onMessage(message: SDKMessage) { + if (message.type === 'system') { + const systemMessage = message as SDKSystemMessage; + if (systemMessage.model) { + lastSystemModel = systemMessage.model; + } + const details = buildClaudeContextDetails({ system: systemMessage, model: systemMessage.model }); + if (details) { + publishContextDetails(session.client, details); + } + } else if (message.type === 'assistant') { + const assistantMessage = message as SDKAssistantMessage; + const rawAssistantPayload = assistantMessage as unknown as Record; + const rawMessage = assistantMessage.message as unknown as Record; + const details = buildClaudeContextDetails({ + contextUsage: assistantMessage.context_usage ?? rawAssistantPayload.context_usage, + messageUsage: rawMessage.usage, + model: assistantMessage.model ?? (typeof rawAssistantPayload.model === 'string' ? rawAssistantPayload.model : null) + }); + if (details) { + publishContextDetails(session.client, details); + } + } else if (message.type === 'result') { + const resultMessage = message as SDKResultMessage; + const details = buildClaudeContextDetails({ + result: resultMessage, + messageUsage: resultMessage.usage, + model: resultMessage.model ?? lastSystemModel + }); + if (details) { + publishContextDetails(session.client, details); + } + } + formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index b8bccece8b..2a600101a0 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -28,6 +28,7 @@ import { toConversationHistoryCapabilities } from '@hapi/protocol/conversationHistory'; import { listSkills, type SkillSummary } from '@/modules/common/skills'; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; export interface StartOptions { model?: string @@ -107,6 +108,18 @@ export async function runClaude(options: StartOptions = {}): Promise { catalogPromise = loadCatalog().then((result) => { const { sdkMetadata, catalog } = result; logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); + const staticContextDetails = buildClaudeContextDetails({ + model: options.model, + system: { + model: options.model, + tools: sdkMetadata.tools, + skills: catalog.skills, + slash_commands: catalog.commands + } + }); + if (staticContextDetails) { + publishContextDetails(session, staticContextDetails); + } if (sdkMetadata.slashCommands === undefined) { catalogPromise = null; if (sdkMetadata.tools !== undefined) { diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index a05bbf6694..cfe6b64544 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -39,6 +39,8 @@ export interface SDKUserMessage extends SDKMessage { export interface SDKAssistantMessage extends SDKMessage { type: 'assistant' parent_tool_use_id?: string + model?: string + context_usage?: unknown message: { role: 'assistant' content: Array<{ @@ -96,6 +98,7 @@ export interface SDKResultMessage extends SDKMessage { duration_api_ms: number is_error: boolean session_id: string + model?: string } export interface SDKControlResponse extends SDKMessage { diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 801c32d520..d0a67f7d6d 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -302,6 +302,12 @@ export class CodexAppServerClient extends JsonLineParser { return response as SkillsListResponse; } + async listMcpServerStatuses(): Promise { + return await this.sendRequest('mcpServerStatus/list', {}, { + timeoutMs: 30_000 + }); + } + async listCollaborationModes(): Promise { const response = await this.sendRequest('collaborationMode/list', {}, { timeoutMs: 30_000 diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 6d7ad7e16a..0f3200cea3 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -7,7 +7,10 @@ import { tmpdir } from 'node:os'; const harness = { launches: [] as Array>, sessionHookHandlers: [] as Array<(sessionId: string, data: Record) => void>, - runBarrier: null as Promise | null + runBarrier: null as Promise | null, + inventorySlashCommands: null as Promise> | null, + inventorySkills: null as Promise> | null, + inventoryMcp: null as Promise | null }; vi.mock('./codexLocal', () => ({ @@ -55,6 +58,18 @@ vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ } })); +vi.mock('@/modules/common/slashCommands', () => ({ + listSlashCommands: async () => harness.inventorySlashCommands ?? [{ name: '/compact' }] +})); + +vi.mock('@/modules/common/skills', () => ({ + listSkills: async () => harness.inventorySkills ?? [{ name: 'find-docs', description: 'Find docs' }] +})); + +vi.mock('./utils/codexMcpInventory', () => ({ + listConfiguredCodexMcpServers: async () => harness.inventoryMcp ?? [] +})); + import { codexLocalLauncher } from './codexLocalLauncher'; function createQueueStub() { @@ -91,6 +106,7 @@ function createSessionStub( let transcriptPath: string | null = initialTranscriptPath; let transcriptHistoryReplayPending = replayTranscriptHistoryOnStart; let modelReasoningEffort: string | null = null; + let metadata: Record = {}; const modelReasoningEffortUpdates: Array = []; const transcriptPathCallbacks: Array<(path: string) => void> = []; @@ -112,6 +128,10 @@ function createSessionStub( }, client: { isPending: () => pendingClient, + getMetadata: () => metadata, + updateMetadata: (handler: (value: Record) => Record) => { + metadata = handler(metadata); + }, rpcHandlerManager: { registerHandler: () => {} } @@ -170,7 +190,8 @@ function createSessionStub( getUserActivityCount: () => userActivityCount, getLocalLaunchFailure: () => localLaunchFailure, getModelReasoningEffort: () => modelReasoningEffort, - getModelReasoningEffortUpdates: () => modelReasoningEffortUpdates + getModelReasoningEffortUpdates: () => modelReasoningEffortUpdates, + getContextDetails: () => metadata.contextDetails }; } @@ -202,6 +223,9 @@ describe('codexLocalLauncher', () => { harness.launches = []; harness.sessionHookHandlers = []; harness.runBarrier = null; + harness.inventorySlashCommands = null; + harness.inventorySkills = null; + harness.inventoryMcp = null; }); afterEach(async () => { @@ -344,6 +368,36 @@ describe('codexLocalLauncher', () => { }); }); + it('publishes local Codex inventory before the first token count', async () => { + const { session, getContextDetails } = createSessionStub('default'); + + await codexLocalLauncher(session as never); + + expect(getContextDetails()).toMatchObject({ + provider: 'codex', + codex: { + slashCommands: ['/compact'], + skills: [{ name: 'find-docs' }], + mcpServers: [] + } + }); + }); + + it('starts local Codex without waiting for slow capability discovery', async () => { + let releaseSkills!: (skills: Array<{ name: string; description?: string }>) => void; + harness.inventorySkills = new Promise((resolve) => { + releaseSkills = resolve; + }); + + const { session } = createSessionStub('default'); + + await codexLocalLauncher(session as never); + + expect(harness.launches).toHaveLength(1); + releaseSkills([]); + await Promise.resolve(); + }); + it('creates scanner only after transcript path arrives from SessionStart hook', async () => { const transcriptPath = join(tempDir, 'codex-transcript.jsonl'); const { session, agentMessages } = createSessionStub('default'); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 7cc58645e0..24ec657a28 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -18,6 +18,10 @@ import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator'; import { CodexToolHookBridge, isCodexToolHookEvent } from './utils/codexToolHookBridge'; import { countHookCoveredExecCalls } from './utils/codexExecWrapper'; +import { buildCodexContextDetails, publishContextDetails } from '@/agent/contextDetails'; +import { listSlashCommands } from '@/modules/common/slashCommands'; +import { listSkills } from '@/modules/common/skills'; +import { listConfiguredCodexMcpServers } from './utils/codexMcpInventory'; type ProposedPlanMessage = Extract; type ToolCallMessage = Extract; @@ -79,10 +83,48 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch : session.codexArgs; const cwdOverride = parseCodexCliOverrides(session.codexArgs).cwd; const effectiveCodexCwd = cwdOverride ? resolve(session.path, cwdOverride) : session.path; + let availableSlashCommands: string[] = []; + let availableSkills: Array<{ name: string; enabled: boolean }> = []; + let slashCommandsLoaded = false; + let skillsLoaded = false; + let mcpServerInventory: Awaited> = []; // Start hapi hub for MCP bridge (same as remote mode) const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); logger.debug(`[codex-local]: Started hapi MCP bridge server at ${happyServer.url}`); + const inventoryTask = Promise.all([ + listConfiguredCodexMcpServers(effectiveCodexCwd) + .then((inventory) => { + mcpServerInventory = inventory; + }), + listSlashCommands('codex', effectiveCodexCwd) + .then((commands) => { + availableSlashCommands = commands.map((command) => command.name); + slashCommandsLoaded = true; + }) + .catch((error) => { + logger.debug(`[codex-local]: Failed to list slash commands: ${error instanceof Error ? error.message : String(error)}`); + }), + listSkills(effectiveCodexCwd, { flavor: 'codex' }) + .then((skills) => { + availableSkills = skills.map((skill) => ({ name: skill.name, enabled: true })); + skillsLoaded = true; + }) + .catch((error) => { + logger.debug(`[codex-local]: Failed to list skills: ${error instanceof Error ? error.message : String(error)}`); + }) + ]).then(() => { + if (shuttingDown) return; + publishContextDetails(session.client, buildCodexContextDetails({ + slashCommands: slashCommandsLoaded ? availableSlashCommands : undefined, + skills: skillsLoaded ? availableSkills : undefined, + mcpServers, + mcpServerInventory + })); + }); + void inventoryTask.catch((error) => { + logger.debug(`[codex-local]: Failed to collect capability inventory: ${error instanceof Error ? error.message : String(error)}`); + }); const reportTranscriptSyncFailure = (transcriptPath: string, error: unknown): void => { const detail = error instanceof Error ? error.message : String(error); @@ -214,6 +256,17 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch flushPendingExecWrapper(message.callId, message); } } else { + if (message.type === 'token_count') { + publishContextDetails(session.client, buildCodexContextDetails({ + info: message.info, + model: transcriptModel, + threadId: primarySessionId, + slashCommands: slashCommandsLoaded ? availableSlashCommands : undefined, + skills: skillsLoaded ? availableSkills : undefined, + mcpServers, + mcpServerInventory + })); + } const scopedMessage = message.type !== 'token_count' ? message : context.replayedHistory diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index a8d9d83b16..27cdd58f31 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -14,6 +14,8 @@ const harness = vi.hoisted(() => ({ collaborationModeResponse: { data: [{ mode: 'default' }, { mode: 'plan' }] } as unknown, failListCollaborationModes: false, listSkillsCalls: [] as unknown[], + mcpServerStatusPromise: null as Promise | null, + slashCommandsPromise: null as Promise> | null, skillsListResponse: { data: [{ cwd: '/tmp/hapi-update', @@ -140,6 +142,10 @@ vi.mock('./codexAppServerClient', () => { return harness.skillsListResponse; } + async listMcpServerStatuses(): Promise { + return harness.mcpServerStatusPromise ?? { data: [] }; + } + async setExperimentalFeatureEnablement(params: unknown): Promise { harness.setFeatureEnablementCalls.push(params); if (harness.failSetFeatureEnablement) { @@ -1093,6 +1099,16 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ } })); +vi.mock('./utils/codexMcpInventory', () => ({ + listConfiguredCodexMcpServers: async () => [], + mergeCodexMcpInventories: (...inventories: Array>) => inventories.flat(), + parseCodexMcpStatusResponse: () => [] +})); + +vi.mock('@/modules/common/slashCommands', () => ({ + listSlashCommands: async () => harness.slashCommandsPromise ?? [] +})); + import { codexRemoteLauncher, isCurrentSteerHandler } from './codexRemoteLauncher'; import { INDETERMINATE_SYMBOL } from './codexAppServerClient'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; @@ -1145,6 +1161,7 @@ function createSessionStub( requests: {}, completedRequests: {} }; + let metadata: Record = {}; const rpcHandlers = new Map unknown>(); const client = { @@ -1153,7 +1170,12 @@ function createSessionStub( rpcHandlers.set(method, handler); } }, - updateMetadata(_handler: (metadata: Record) => Record) {}, + getMetadata() { + return metadata; + }, + updateMetadata(handler: (current: Record) => Record) { + metadata = handler(metadata); + }, updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) { agentState = handler(agentState); }, @@ -1241,7 +1263,8 @@ function createSessionStub( getModelReasoningEffort: () => currentModelReasoningEffort, getCollaborationMode: () => currentCollaborationMode, collaborationModes, - getAgentState: () => agentState + getAgentState: () => agentState, + getMetadata: () => metadata }; } @@ -1252,6 +1275,36 @@ describe('codexRemoteLauncher', () => { expect(isCurrentSteerHandler(3, 3, true)).toBe(false); }); + it('does not wait for MCP status enrichment before starting Codex', async () => { + let releaseStatus!: (value: unknown) => void; + harness.mcpServerStatusPromise = new Promise((resolve) => { + releaseStatus = resolve; + }); + + const { session } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(harness.startThreadParams).toHaveLength(1); + releaseStatus({ data: [] }); + await Promise.resolve(); + }); + + it('does not wait for slash-command discovery before starting Codex', async () => { + let releaseCommands!: (commands: Array<{ name: string }>) => void; + harness.slashCommandsPromise = new Promise((resolve) => { + releaseCommands = resolve; + }); + + const { session } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(harness.startThreadParams).toHaveLength(1); + releaseCommands([]); + await Promise.resolve(); + }); + it('steers a queued message into the active turn and acks on dispatch', async () => { harness.suppressTurnCompletion = true; const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false); @@ -1399,6 +1452,8 @@ describe('codexRemoteLauncher', () => { harness.collaborationModeResponse = { data: [{ mode: 'default' }, { mode: 'plan' }] }; harness.failListCollaborationModes = false; harness.listSkillsCalls = []; + harness.mcpServerStatusPromise = null; + harness.slashCommandsPromise = null; harness.skillsListResponse = { data: [{ cwd: '/tmp/hapi-update', @@ -1518,7 +1573,7 @@ describe('codexRemoteLauncher', () => { }); it('uses the native skill catalog for completion and structured turn input', async () => { - const { session, rpcHandlers } = createSessionStub(['$hapi inspect']); + const { session, rpcHandlers, getMetadata } = createSessionStub(['$hapi inspect']); await codexRemoteLauncher(session as never); @@ -1531,6 +1586,9 @@ describe('codexRemoteLauncher', () => { success: true, skills: [{ name: 'hapi', description: 'Manage HAPI' }] }); + expect((getMetadata().contextDetails as { codex?: { skills?: unknown[] } }).codex?.skills).toEqual([{ + name: 'hapi' + }]); expect(harness.startTurnParams[0]?.input).toEqual([ { type: 'skill', name: 'hapi', path: '/home/user/.agents/skills/hapi/SKILL.md' }, { type: 'text', text: ' inspect' } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 46a7706b62..93c3de17f0 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -17,10 +17,17 @@ import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; -import type { SkillMetadata, ThreadGoal, ThreadGoalStatus } from './appServerTypes'; +import type { SkillMetadata, ThreadGoal, ThreadGoalStatus, ThreadStartParams } from './appServerTypes'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { extractErrorInfo } from '@/utils/errorUtils'; +import { buildCodexContextDetails, publishContextDetails, type CodexMcpServerInventory } from '@/agent/contextDetails'; +import { listSlashCommands } from '@/modules/common/slashCommands'; +import { + listConfiguredCodexMcpServers, + mergeCodexMcpInventories, + parseCodexMcpStatusResponse +} from './utils/codexMcpInventory'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { RemoteLauncherBase, @@ -353,6 +360,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const messageBuffer = this.messageBuffer; const appServerClient = this.appServerClient; const appServerEventConverter = new AppServerEventConverter(); + let latestCodexThreadResponse: unknown = null; + let latestCodexThreadParams: ThreadStartParams | undefined; + let availableSlashCommands: string[] = []; + let codexMcpServerInventory: CodexMcpServerInventory[] = []; + let publishCodexInventoryContext: (() => void) | null = null; const normalizeCommand = (value: unknown): string | undefined => { if (typeof value === 'string') { @@ -3228,6 +3240,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (msgType === 'token_count') { const threadId = eventThreadId ?? this.currentThreadId; + const details = buildCodexContextDetails({ + info: msg.info, + model: asString(msg.model) ?? usageModel, + threadId, + threadResponse: latestCodexThreadResponse, + threadParams: latestCodexThreadParams, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers, + mcpServerInventory: codexMcpServerInventory + }); + publishContextDetails(session.client, details); session.sendAgentMessage({ ...addCodexEventScope(msg, 'parent', threadId), model: asString(msg.model) ?? usageModel, @@ -3478,6 +3502,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { })) })); } + publishCodexInventoryContext?.(); }; appServerClient.setNotificationHandler((method, params) => { @@ -3533,6 +3558,63 @@ class CodexRemoteLauncher extends RemoteLauncherBase { emitTitleSummary: false }); this.happyServer = happyServer; + const publishCodexThreadContext = (response: unknown, params: ThreadStartParams, threadId?: string): void => { + latestCodexThreadResponse = response; + latestCodexThreadParams = params; + publishContextDetails(session.client, buildCodexContextDetails({ + model: asString(asRecord(response)?.model), + threadResponse: response, + threadParams: params, + threadId: threadId ?? asString(asRecord(asRecord(response)?.thread)?.id), + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers, + mcpServerInventory: codexMcpServerInventory + })); + }; + + publishCodexInventoryContext = () => { + const response = latestCodexThreadResponse; + publishContextDetails(session.client, buildCodexContextDetails({ + model: asString(asRecord(response)?.model), + threadResponse: response, + threadParams: latestCodexThreadParams, + threadId: this.currentThreadId, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers, + mcpServerInventory: codexMcpServerInventory + })); + }; + + const initialCodexContextDetails = buildCodexContextDetails({ + threadParams: undefined, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers, + mcpServerInventory: codexMcpServerInventory + }); + if (initialCodexContextDetails.codex) { + publishContextDetails(session.client, initialCodexContextDetails); + } + void listSlashCommands('codex', session.path) + .then((commands) => { + if (this.shouldExit) return; + availableSlashCommands = commands.map((command) => command.name); + publishCodexInventoryContext?.(); + }) + .catch((error) => { + logger.debug(`[Codex] failed to list slash commands: ${errorMessage(error)}`); + }); + void listConfiguredCodexMcpServers(session.path) + .then((inventory) => { + if (this.shouldExit) return; + codexMcpServerInventory = mergeCodexMcpInventories(codexMcpServerInventory, inventory); + publishCodexInventoryContext?.(); + }) + .catch((error) => { + logger.debug(`[Codex] failed to list configured MCP servers: ${errorMessage(error)}`); + }); this.setupAbortHandlers(session.client.rpcHandlerManager, { onAbort: () => this.handleAbort(), @@ -3566,6 +3648,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }); + void appServerClient.listMcpServerStatuses() + .then((response) => { + if (this.shouldExit) return; + const statusInventory = parseCodexMcpStatusResponse(response); + if (statusInventory.length === 0) return; + codexMcpServerInventory = mergeCodexMcpInventories(codexMcpServerInventory, statusInventory); + publishCodexInventoryContext?.(); + }) + .catch((error) => { + logger.debug(`[Codex] mcpServerStatus/list failed: ${errorMessage(error)}`); + }); + const publishConversationHistoryCapabilities = async () => { const conversationHistory = this.conversationHistory.getCapabilitiesForMetadata()?.conversationHistory try { @@ -3730,6 +3824,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); + publishCodexThreadContext(resumeResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3794,6 +3889,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); + publishCodexThreadContext(resumeResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3827,6 +3923,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } + publishCodexThreadContext(threadResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -4092,6 +4189,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const responseThread = responseRecord ? asRecord(responseRecord.thread) : null; threadId = asString(responseThread?.id) ?? resumeCandidate; applyResolvedModel(responseRecord?.model); + publishCodexThreadContext(response, threadParams, threadId); logger.debug(shouldForkImportedSource ? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}` : `[Codex] Resumed app-server thread ${threadId}`); @@ -4120,6 +4218,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } + publishCodexThreadContext(threadResponse, threadParams, threadId); } if (!threadId) { diff --git a/cli/src/codex/utils/codexMcpInventory.test.ts b/cli/src/codex/utils/codexMcpInventory.test.ts new file mode 100644 index 0000000000..a8a7773444 --- /dev/null +++ b/cli/src/codex/utils/codexMcpInventory.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + mergeCodexMcpInventories, + parseCodexMcpInventoryOutput, + parseCodexMcpStatusResponse +} from './codexMcpInventory' + +describe('codex MCP inventory', () => { + it('parses configured non-HAPI servers without persisting commands or secrets', () => { + const inventory = parseCodexMcpInventoryOutput(JSON.stringify([{ + name: 'qmd', + enabled: true, + transport: { + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { TOKEN: 'secret' } + }, + auth_status: 'unsupported' + }])) + + expect(inventory).toEqual([{ name: 'qmd' }]) + expect(JSON.stringify(inventory)).not.toContain('secret') + expect(JSON.stringify(inventory)).not.toContain('server.js') + }) + + it('parses resolved server status and tool names', () => { + expect(parseCodexMcpStatusResponse({ + data: [{ + name: 'qmd', + status: 'ready', + tools: [{ name: 'search' }, { name: 'fetch' }] + }] + })).toEqual([{ + name: 'qmd', + status: 'ready', + toolNames: ['search', 'fetch'] + }]) + }) + + it('merges resolved fields over configured server names', () => { + expect(mergeCodexMcpInventories( + [{ name: 'qmd' }, { name: 'other' }], + [{ name: 'qmd', status: 'ready', toolNames: ['search'] }] + )).toEqual([ + { name: 'other' }, + { name: 'qmd', status: 'ready', toolNames: ['search'] } + ]) + }) +}) diff --git a/cli/src/codex/utils/codexMcpInventory.ts b/cli/src/codex/utils/codexMcpInventory.ts new file mode 100644 index 0000000000..c61722e032 --- /dev/null +++ b/cli/src/codex/utils/codexMcpInventory.ts @@ -0,0 +1,129 @@ +import spawn from 'cross-spawn' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { resolveCodexCommand } from './codexExecutable' +import type { CodexMcpServerInventory } from '@/agent/contextDetails' + +export const CODEX_MCP_LIST_TIMEOUT_MS = 5_000 + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function asToolNames(value: unknown): string[] | undefined { + const items = Array.isArray(value) + ? value + : asRecord(value) + ? Object.entries(asRecord(value)!).map(([name]) => name) + : [] + if (items.length === 0) return undefined + const names = items.flatMap((item) => { + const name = typeof item === 'string' ? asString(item) : asString(asRecord(item)?.name) + return name ? [name] : [] + }) + return names.length > 0 ? Array.from(new Set(names)) : undefined +} + +function parseInventoryEntries(value: unknown): CodexMcpServerInventory[] { + const record = asRecord(value) + const entries = Array.isArray(value) + ? value + : Array.isArray(record?.data) + ? record.data + : [] + + return entries.flatMap((entry) => { + const item = asRecord(entry) + const name = asString(item?.name ?? item?.serverName ?? item?.server_name) + if (!name) return [] + const enabled = item?.enabled + const status = asString( + item?.status + ?? item?.state + ?? item?.authStatus + ?? item?.auth_status + ?? (enabled === false ? 'disabled' : undefined) + ) + const toolNames = asToolNames(item?.toolNames ?? item?.tool_names ?? item?.tools) + return [{ + name, + ...(toolNames ? { toolNames } : {}), + ...(status && status !== 'unsupported' ? { status } : {}) + }] + }) +} + +export function parseCodexMcpInventoryOutput(output: string): CodexMcpServerInventory[] { + try { + return parseInventoryEntries(JSON.parse(output)) + } catch { + return [] + } +} + +export function parseCodexMcpStatusResponse(value: unknown): CodexMcpServerInventory[] { + return parseInventoryEntries(value) +} + +export function mergeCodexMcpInventories( + ...inventories: readonly CodexMcpServerInventory[][] +): CodexMcpServerInventory[] { + const byName = new Map() + for (const inventory of inventories) { + for (const server of inventory) { + const previous = byName.get(server.name) + byName.set(server.name, { + ...previous, + ...server, + ...(server.toolNames === undefined && previous?.toolNames + ? { toolNames: previous.toolNames } + : {}), + ...(server.status === undefined && previous?.status + ? { status: previous.status } + : {}) + }) + } + } + return Array.from(byName.values()).sort((left, right) => left.name.localeCompare(right.name)) +} + +export function listConfiguredCodexMcpServers(cwd?: string): Promise { + const resolved = resolveCodexCommand() + return new Promise((resolveInventory) => { + let stdout = '' + let settled = false + const child = spawn(resolved.command, [ + ...resolved.args, + 'mcp', + 'list', + '--json' + ], { + env: withBunRuntimeEnv(), + ...(cwd ? { cwd } : {}), + windowsHide: process.platform === 'win32' + }) + const finish = (inventory: CodexMcpServerInventory[]): void => { + if (settled) return + settled = true + clearTimeout(timeout) + resolveInventory(inventory) + } + const timeout = setTimeout(() => { + child.kill() + finish([]) + }, CODEX_MCP_LIST_TIMEOUT_MS) + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + }) + child.on('error', () => finish([])) + child.on('close', (code) => { + finish(code === 0 ? parseCodexMcpInventoryOutput(stdout) : []) + }) + }) +} diff --git a/shared/src/contextDetails.ts b/shared/src/contextDetails.ts new file mode 100644 index 0000000000..666f232be8 --- /dev/null +++ b/shared/src/contextDetails.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' + +const TokenCountSchema = z.number().int().nonnegative() + +export const ContextUsageSnapshotSchema = z.object({ + contextTokens: TokenCountSchema.optional(), + cacheReadTokens: TokenCountSchema.optional() +}) + +export type ContextUsageSnapshot = z.infer + +const ClaudeSkillSchema = z.object({ + name: z.string() +}) + +const ClaudeMcpToolSchema = z.object({ + name: z.string(), + serverName: z.string().optional() +}) + +export const ClaudeContextDetailsSchema = z.object({ + skills: z.array(ClaudeSkillSchema).optional(), + mcpTools: z.array(ClaudeMcpToolSchema).optional(), + systemTools: z.array(z.string()).optional(), + slashCommands: z.array(z.string()).optional() +}) + +export type ClaudeContextDetails = z.infer + +const CodexSkillSchema = z.object({ + name: z.string() +}) + +const CodexMcpServerSchema = z.object({ + name: z.string(), + toolNames: z.array(z.string()).optional(), + status: z.string().optional() +}) + +export const CodexContextDetailsSchema = z.object({ + slashCommands: z.array(z.string()).optional(), + skills: z.array(CodexSkillSchema).optional(), + mcpServers: z.array(CodexMcpServerSchema).optional() +}) + +export type CodexContextDetails = z.infer + +export const ContextDetailsSchema = z.object({ + version: z.literal(1), + updatedAt: z.number().int().nonnegative(), + provider: z.enum(['claude', 'codex']), + model: z.string().optional(), + contextWindow: TokenCountSchema.optional(), + usage: ContextUsageSnapshotSchema.optional(), + claude: ClaudeContextDetailsSchema.optional(), + codex: CodexContextDetailsSchema.optional() +}) + +export type ContextDetails = z.infer diff --git a/shared/src/index.ts b/shared/src/index.ts index 4fa14330f9..a6b0b92507 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -21,6 +21,7 @@ export * from './piThinkingLevel' export * from './runnerCapabilities' export * from './agentConfig' export * from './copilotModes' +export * from './contextDetails' export * from './slashCommands' export * from './utils' export * from './usage' diff --git a/shared/src/schemas.metadata.test.ts b/shared/src/schemas.metadata.test.ts index d3e0189463..318b1d255d 100644 --- a/shared/src/schemas.metadata.test.ts +++ b/shared/src/schemas.metadata.test.ts @@ -24,4 +24,33 @@ describe('MetadataSchema cursorSessionProtocol', () => { expect(result.success).toBe(true); expect(result.data?.conversationHistoryEntryIds).toEqual({ 'local-user-id': 'pi-entry-id' }); }); + + it('accepts versioned Claude and Codex context details', () => { + const result = MetadataSchema.safeParse({ + ...base, + contextDetails: { + version: 1, + updatedAt: 123, + provider: 'codex', + model: 'gpt-5-codex', + contextWindow: 258_400, + usage: { contextTokens: 12_000, cacheReadTokens: 8_000 }, + codex: { + slashCommands: ['clear'], + skills: [{ name: 'find-docs', scope: 'user' }], + mcpServers: [{ name: 'hapi', toolNames: ['change_title'] }] + }, + claude: { + systemTools: ['Read', 'Bash'], + slashCommands: ['/context', '/compact'] + } + } + }); + + expect(result.success).toBe(true); + expect(result.data?.contextDetails?.codex?.skills?.[0]?.name).toBe('find-docs'); + expect(result.data?.contextDetails?.codex?.slashCommands).toEqual(['clear']); + expect(result.data?.contextDetails?.claude?.systemTools).toEqual(['Read', 'Bash']); + expect('sandbox' in (result.data?.contextDetails?.codex ?? {})).toBe(false); + }); }); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 167f95d6f4..621fe11fa3 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { COPILOT_AGENT_MODES, type CopilotAgentMode } from './copilotModes' import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' import { AgentConfigDescriptorSchema } from './agentConfig' +import { ContextDetailsSchema } from './contextDetails' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) @@ -159,7 +160,8 @@ export const MetadataSchema = z.object({ // field stores only modelId (shared across all flavors); this preserves // the provider so web can resolve the exact model when two providers // share a modelId. - piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional(), + contextDetails: ContextDetailsSchema.optional() }) export type Metadata = z.infer diff --git a/shared/src/types.ts b/shared/src/types.ts index df693464ca..1bdf6656d7 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -27,6 +27,12 @@ export type { export type { SessionSummary, SessionSummaryMetadata, PendingRequest, PendingRequestKind } from './sessionSummary' export { PENDING_REQUEST_SUMMARY_CAP } from './sessionSummary' +export type { + ClaudeContextDetails, + CodexContextDetails, + ContextDetails, + ContextUsageSnapshot +} from './contextDetails' export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' export type { diff --git a/web/src/components/AssistantChat/ContextDetailsDialog.tsx b/web/src/components/AssistantChat/ContextDetailsDialog.tsx new file mode 100644 index 0000000000..673698ee86 --- /dev/null +++ b/web/src/components/AssistantChat/ContextDetailsDialog.tsx @@ -0,0 +1,197 @@ +import { useState, type ReactNode } from 'react' +import type { ContextDetails } from '@hapi/protocol' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' + +function formatSlashCommand(value: string): string { + return value.startsWith('/') ? value : `/${value}` +} + +function SimpleList(props: { items: readonly string[] }) { + return ( +
+ {props.items.map((item, index) => ( +
{item}
+ ))} +
+ ) +} + +function Section(props: { title: string; count?: number; children: ReactNode }) { + return ( +
+
+

{props.title}

+ {props.count !== undefined ? ( + + {props.count} + + ) : null} +
+
+ {props.children} +
+
+ ) +} + +function ClaudeDetails(props: { details: ContextDetails }) { + const { t } = useTranslation() + const claude = props.details.claude + if (!claude) return null + return ( + <> + {claude.slashCommands?.length ? ( +
+ +
+ ) : null} + {claude.skills?.length ? ( +
+ skill.name)} /> +
+ ) : null} + {claude.mcpTools?.length ? ( +
+ +
+ ) : null} + {claude.systemTools?.length ? ( +
+ +
+ ) : null} + + ) +} + +function ClaudeMcpTools(props: { tools: NonNullable['mcpTools']> }) { + const groups = new Map() + for (const tool of props.tools) { + const serverName = tool.serverName ?? '' + const names = groups.get(serverName) ?? [] + names.push(tool.name) + groups.set(serverName, names) + } + + return ( +
+ {Array.from(groups.entries()).map(([serverName, tools]) => ( +
+ {serverName ?
{serverName}
: null} +
+ {tools.map((tool, index) =>
{tool}
)} +
+
+ ))} +
+ ) +} + +function CodexDetails(props: { details: ContextDetails }) { + const { t } = useTranslation() + const codex = props.details.codex + if (!codex) return null + return ( + <> + {codex.slashCommands?.length ? ( +
+ +
+ ) : null} + {codex.skills?.length ? ( +
+ skill.name)} /> +
+ ) : null} + {codex.mcpServers?.length ? ( +
+
+ {codex.mcpServers.map((server) => ( +
+
+ {server.name} + {server.status ? {server.status} : null} +
+ {server.toolNames?.length ? ( +
+ {server.toolNames.map((toolName) =>
{toolName}
)} +
+ ) : ( +
{t('misc.contextMcpServer')}
+ )} +
+ ))} +
+
+ ) : null} + + ) +} + +function hasVisibleDetails(details: ContextDetails | null | undefined): boolean { + if (!details) return false + if (details.provider === 'claude') { + return Boolean( + details.claude?.systemTools?.length + || details.claude?.slashCommands?.length + || details.claude?.skills?.length + || details.claude?.mcpTools?.length + ) + } + return Boolean( + details.codex?.slashCommands?.length + || details.codex?.skills?.length + || details.codex?.mcpServers?.length + ) +} + +export function ContextDetailsDialog(props: { + details?: ContextDetails | null + triggerClassName: string + triggerContent: ReactNode + triggerAriaLabel?: string +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const details = props.details + + return ( + + + + + + + {t('misc.contextAgentDetailsTitle')} + + +
+
+ {details?.provider === 'claude' ? : null} + {details?.provider === 'codex' ? : null} + {!hasVisibleDetails(details) ? ( +
{t('misc.contextNoDetails')}
+ ) : null} +
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index a9993972b8..a1dd17fc6f 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,6 +1,7 @@ import { getCodexCollaborationModeOptions, getCopilotAgentModeOptions, + type ContextDetails, getPermissionModeOptionsForFlavor, type CopilotAgentMode } from '@hapi/protocol' @@ -298,6 +299,10 @@ export function HappyComposer(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + contextDetails?: ContextDetails | null + /** Legacy Claude capability lists stored at the metadata top level. */ + legacyTools?: readonly string[] | null + legacySlashCommands?: readonly string[] | null /** Model for the context-window heuristic; see StatusBar.contextModel. */ contextModel?: string | null controlledByUser?: boolean @@ -405,6 +410,9 @@ export function HappyComposer(props: { contextSize, contextCacheRead, contextWindow, + contextDetails, + legacyTools, + legacySlashCommands, contextModel, controlledByUser = false, agentFlavor, @@ -2166,6 +2174,9 @@ export function HappyComposer(props: { contextSize={contextSize} contextCacheRead={contextCacheRead} contextWindow={contextWindow} + contextDetails={contextDetails} + legacyTools={legacyTools} + legacySlashCommands={legacySlashCommands} contextModel={contextModel} model={model} modelReasoningEffort={modelReasoningEffort} diff --git a/web/src/components/AssistantChat/StatusBar.popover.test.tsx b/web/src/components/AssistantChat/StatusBar.popover.test.tsx index 4e0acea608..5f524a08a7 100644 --- a/web/src/components/AssistantChat/StatusBar.popover.test.tsx +++ b/web/src/components/AssistantChat/StatusBar.popover.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' import { StatusBar } from './StatusBar' -describe('StatusBar context details popover', () => { +describe('StatusBar context details dialog', () => { beforeEach(() => { localStorage.clear() }) @@ -49,6 +49,25 @@ describe('StatusBar context details popover', () => { expect(thinkingLabel.parentElement?.className.split(' ')).toContain('sm:top-0.5') }) + it('keeps the connection state in the agent-details accessible name', () => { + localStorage.setItem('hapi-lang', 'en') + const { rerender } = render( + + + + ) + + expect(screen.getByRole('button', { name: 'Agent context details: online' })).toBeInTheDocument() + + rerender( + + + + ) + + expect(screen.getByRole('button', { name: 'Agent context details: offline' })).toBeInTheDocument() + }) + it('uses an effort-only reasoning label on mobile and the full label on desktop', () => { render( @@ -214,7 +233,7 @@ describe('StatusBar context details popover', () => { const cacheLine = await screen.findByText('缓存:86k') const details = cacheLine.parentElement expect(details?.textContent).toBe('缓存:86k使用:90k(35%)剩余:168k(65%)') - expect(screen.queryByText('上下文详情')).toBeNull() + expect(screen.queryByRole('heading', { name: '上下文详情' })).not.toBeInTheDocument() }) it('localizes the popover content without localizing the external left label', async () => { @@ -240,4 +259,197 @@ describe('StatusBar context details popover', () => { const cacheLine = await screen.findByText('Cache: 86k') expect(cacheLine.parentElement?.textContent).toBe('Cache: 86kUsed: 90k (35%)Remaining: 168k (65%)') }) + + it('renders Claude system tools, slash commands, and skills from the agent status entry', async () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /Agent context details/ })) + + expect(screen.getByRole('heading', { name: 'Agent details' })).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + expect(dialog.className.split(' ')).toContain('overflow-hidden') + expect(dialog.querySelector('.agent-details-scroll-y')?.className.split(' ')).toEqual( + expect.arrayContaining(['min-h-0', 'flex-1', 'overflow-y-auto']) + ) + expect(screen.getByRole('dialog').className.split(' ')).toEqual(expect.arrayContaining(['px-2', 'py-4'])) + expect(screen.getByRole('dialog').style.paddingBottom).toBe('') + expect(screen.getByRole('button', { name: 'Close' }).className.split(' ')).toEqual( + expect.arrayContaining(['top-2', 'z-20']) + ) + expect(screen.getByRole('heading', { name: 'Agent details' }).parentElement?.className.split(' ')).toEqual( + expect.arrayContaining(['items-center', 'bg-[var(--app-dialog-bg)]', 'pb-4', 'pr-0', 'text-center', 'sm:text-center']) + ) + expect(screen.queryByText('Claude')).not.toBeInTheDocument() + expect(screen.queryByText('claude-sonnet')).not.toBeInTheDocument() + expect(screen.getByText('System tools')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/context')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.getByText('Skills')).toBeInTheDocument() + expect(screen.getByText(/find-docs/)).toBeInTheDocument() + expect(screen.queryByText('Find docs')).not.toBeInTheDocument() + expect(screen.getByText('MCP tools')).toBeInTheDocument() + expect(screen.getByText('mcp__hapi__list_peers')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Agent commands' }).parentElement?.textContent).toContain('2') + expect(screen.getByRole('heading', { name: 'Skills' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'MCP tools' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'System tools' }).parentElement?.textContent).toContain('2') + expect(screen.queryByText('Command')).not.toBeInTheDocument() + expect(screen.queryByText('Tool')).not.toBeInTheDocument() + expect(screen.getByRole('dialog').querySelector('.divide-y')).toBeNull() + expect(Array.from(screen.getByRole('dialog').querySelectorAll('section h3')).map((heading) => heading.textContent)).toEqual([ + 'Agent commands', + 'Skills', + 'MCP tools', + 'System tools' + ]) + expect(screen.queryByText('Context window')).not.toBeInTheDocument() + expect(screen.queryByText('Input')).not.toBeInTheDocument() + expect(screen.queryByText('Context categories')).not.toBeInTheDocument() + expect(screen.queryByText('Resources')).not.toBeInTheDocument() + }) + + it('recovers Claude lists from legacy top-level session metadata', () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /Agent context details/ })) + + expect(screen.getByText('System tools')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.queryByText('No detailed context information available.')).not.toBeInTheDocument() + }) + + it('does not resurrect legacy Claude lists after an authoritative empty refresh', () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /Agent context details/ })) + + expect(screen.queryByText('Read')).not.toBeInTheDocument() + expect(screen.queryByText('/compact')).not.toBeInTheDocument() + expect(screen.getByText('This agent has not reported additional context details yet.')).toBeInTheDocument() + }) + + it('renders detailed Codex skills and MCP tools from the agent status entry', async () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /Agent context details/ })) + + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/clear')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.getByText('find-docs')).toBeInTheDocument() + expect(screen.queryByText('Find docs')).not.toBeInTheDocument() + expect(screen.getByText('MCP servers')).toBeInTheDocument() + expect(screen.getByText('change_title')).toBeInTheDocument() + expect(screen.getByText('list_peers')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Agent commands' }).parentElement?.textContent).toContain('2') + expect(screen.getByRole('heading', { name: 'Skills' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'MCP servers' }).parentElement?.textContent).toContain('1') + expect(screen.queryByText('Command')).not.toBeInTheDocument() + expect(screen.getByRole('dialog').querySelector('.divide-y')).toBeNull() + expect(Array.from(screen.getByRole('dialog').querySelectorAll('section h3')).map((heading) => heading.textContent)).toEqual([ + 'Agent commands', + 'Skills', + 'MCP servers' + ]) + expect(screen.queryByText('System tools')).not.toBeInTheDocument() + expect(screen.queryByText('Context window')).not.toBeInTheDocument() + expect(screen.queryByText('Input')).not.toBeInTheDocument() + expect(screen.queryByText('Runtime')).not.toBeInTheDocument() + }) + }) diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 5b1f71cbb0..ba66f5aa82 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -5,8 +5,7 @@ import { getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' -import type { PermissionModeTone } from '@hapi/protocol' -import * as Popover from '@radix-ui/react-popover' +import type { ContextDetails, PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' import type { ConversationStatus } from '@/realtime/types' @@ -17,9 +16,11 @@ import { getReasoningEffortForFlavor, shouldShowReasoningStatusLabel } from '@/lib/codexStatusLabels' +import * as Popover from '@radix-ui/react-popover' import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' +import { ContextDetailsDialog } from './ContextDetailsDialog' // Vibing messages for thinking state const VIBING_MESSAGES = [ @@ -50,6 +51,14 @@ const PERMISSION_TONE_CLASSES: Record = { const CONTEXT_WARNING_THRESHOLD_PERCENT = 70 const CONTEXT_DANGER_THRESHOLD_PERCENT = 90 +type ContextUsageSummary = { + cacheRead: string | null + used: string + usedPercentage: number | null + remaining: string | null + remainingPercentage: number | null +} + function getConnectionStatus( active: boolean, thinking: boolean, @@ -191,6 +200,43 @@ export function shouldShowCodexFastBadge( return agentFlavor === 'codex' && isFastServiceTier(serviceTier) } +function normalizeLegacyList(values: readonly string[] | null | undefined): string[] | undefined { + if (!values) return undefined + const normalized = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) + return normalized.length > 0 ? normalized : undefined +} + +function addLegacyClaudeLists(args: { + details: ContextDetails | null | undefined + agentFlavor: string | null | undefined + tools: readonly string[] | null | undefined + slashCommands: readonly string[] | null | undefined +}): ContextDetails | null | undefined { + const isClaude = args.details?.provider === 'claude' || (!args.details && args.agentFlavor === 'claude') + if (!isClaude) return args.details + + const legacyTools = normalizeLegacyList(args.tools) + const legacySlashCommands = normalizeLegacyList(args.slashCommands) + const claude = args.details?.claude + const systemTools = claude && 'systemTools' in claude ? claude.systemTools : legacyTools + const slashCommands = claude && 'slashCommands' in claude ? claude.slashCommands : legacySlashCommands + if (!systemTools && !slashCommands) return args.details + + return { + ...(args.details ?? { version: 1, updatedAt: 0, provider: 'claude' as const }), + provider: 'claude', + claude: { + ...claude, + ...(systemTools + ? { systemTools } + : {}), + ...(slashCommands + ? { slashCommands } + : {}) + } + } +} + export function StatusBar(props: { active: boolean thinking: boolean @@ -199,6 +245,10 @@ export function StatusBar(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + contextDetails?: ContextDetails | null + /** Legacy Claude metadata retained on sessions created before nested lists were added. */ + legacyTools?: readonly string[] | null + legacySlashCommands?: readonly string[] | null /** * Model to use for the context-window fallback heuristic when * contextWindow is absent. Falls back to `model`. Callers pass the @@ -223,32 +273,44 @@ export function StatusBar(props: { [props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t] ) - const contextHeuristicModel = props.contextModel ?? props.model + const effectiveContextSize = props.contextSize ?? props.contextDetails?.usage?.contextTokens + const effectiveContextWindow = props.contextWindow ?? props.contextDetails?.contextWindow + const effectiveContextCacheRead = props.contextCacheRead ?? props.contextDetails?.usage?.cacheReadTokens + const contextHeuristicModel = props.contextModel ?? props.contextDetails?.model ?? props.model const contextWarning = useMemo( () => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) if (!maxContextSize) return null - return getContextWarning(props.contextSize, maxContextSize) + return getContextWarning(effectiveContextSize, maxContextSize) }, - [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor] + [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor] ) const contextUsageLabel = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return formatContextUsageLabel(props.contextSize, maxContextSize) - }, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return formatContextUsageLabel(effectiveContextSize, maxContextSize) + }, [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) const compactContextUsageLabel = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return formatCompactContextUsageLabel(props.contextSize, maxContextSize) - }, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]) - const contextUsageDetails = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return getContextUsageDetails(props.contextSize, maxContextSize, props.contextCacheRead) - }, [props.contextSize, props.contextCacheRead, props.contextWindow, contextHeuristicModel, props.agentFlavor]) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return formatCompactContextUsageLabel(effectiveContextSize, maxContextSize) + }, [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) + const contextUsageDetails: ContextUsageSummary | null = useMemo(() => { + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return getContextUsageDetails(effectiveContextSize, maxContextSize, effectiveContextCacheRead) + }, [effectiveContextSize, effectiveContextCacheRead, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null + const displayContextDetails = useMemo( + () => addLegacyClaudeLists({ + details: props.contextDetails, + agentFlavor: props.agentFlavor, + tools: props.legacyTools, + slashCommands: props.legacySlashCommands + }), + [props.contextDetails, props.agentFlavor, props.legacyTools, props.legacySlashCommands] + ) const permissionMode = props.permissionMode // Copilot always shows permission (including Default) so model=auto sessions @@ -294,14 +356,22 @@ export function StatusBar(props: { return (
-
- - - {connectionStatus.text} - -
+ +