Path: blob/main/extensions/copilot/src/extension/chronicle/common/sessionStoreTracking.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { GenAiAttr } from '../../../platform/otel/common/genAiAttributes';6import type { ICompletedSpanData } from '../../../platform/otel/common/otelService';78/**9* Helpers for extracting file paths and refs from tool calls,10* plus shared constants for session store truncation limits.11*/1213// ── Truncation limits (shared by sessionStoreTracker and sessionReindexer) ──1415/** Maximum characters stored for user_message. */16export const MAX_USER_MESSAGE_LENGTH = 100;1718/** Maximum characters stored for assistant_response. */19export const MAX_ASSISTANT_RESPONSE_LENGTH = 1000;2021/** Maximum characters stored for session summary. */22export const MAX_SUMMARY_LENGTH = 100;2324/**25* Truncate a string to at most `maxLength` stored characters, appending '...' if truncated.26* The returned value, including the truncation suffix, never exceeds `maxLength`.27* Returns `undefined` for falsy input.28*/29export function truncateForStore(value: string | undefined, maxLength: number): string | undefined {30if (!value) {31return undefined;32}33if (value.length <= maxLength) {34return value;35}36const ellipsis = '...';37if (maxLength <= ellipsis.length) {38return ellipsis.slice(0, maxLength);39}40return value.slice(0, maxLength - ellipsis.length).trimEnd() + ellipsis;41}4243/** Terminal/shell tool names that may produce refs. */44export function isTerminalTool(toolName: string): boolean {45return toolName === 'runInTerminal' || toolName === 'run_in_terminal';46}4748/**49* Extract tool arguments from an OTel span.50* Parses the serialized JSON from gen_ai.tool.call.arguments attribute.51* @internal Exported for testing.52*/53export function extractToolArgs(span: ICompletedSpanData): Record<string, unknown> {54const serialized = span.attributes[GenAiAttr.TOOL_CALL_ARGUMENTS];55if (typeof serialized === 'string') {56try {57return JSON.parse(serialized) as Record<string, unknown>;58} catch {59// ignore parse errors60}61}62return {};63}6465/** Tools whose arguments contain a file path being modified or read. */66const FILE_TRACKING_TOOLS = new Set([67// VS Code model-facing tool names (from ToolName enum)68'replace_string_in_file',69'multi_replace_string_in_file',70'insert_edit_into_file',71'create_file',72'create_directory',73'edit_notebook_file',74'apply_patch',75'read_file',76'view_image',77'list_dir',78// CLI-agent tool names (backward compat)79'str_replace_editor',80'create',81]);8283/** GitHub MCP server tool prefixes. */84const GH_MCP_PREFIXES = ['mcp_github_', 'github-mcp-server-'];8586/**87* Extract absolute file path from tool arguments if available.88* Handles both CLI-style (edit/create with `path`) and VS Code-style tools89* that use `filePath`, as well as `apply_patch` which encodes paths in the patch input.90* @internal Exported for testing.91*/92export function extractFilePath(toolName: string, toolArgs: unknown): string | undefined {93if (!FILE_TRACKING_TOOLS.has(toolName)) { return undefined; }94if (typeof toolArgs !== 'object' || toolArgs === null) { return undefined; }95const args = toolArgs as Record<string, unknown>;9697// VS Code tools use 'filePath', CLI tools use 'path', list_dir uses 'path',98// create_directory uses 'dirPath'99const filePath = args.filePath ?? args.path ?? args.dirPath;100if (typeof filePath === 'string') { return filePath; }101102// multi_replace_string_in_file stores filePath in each replacement item103if (toolName === 'multi_replace_string_in_file' && Array.isArray(args.replacements)) {104const first = args.replacements[0];105if (typeof first === 'object' && first !== null) {106const fp = (first as Record<string, unknown>).filePath;107if (typeof fp === 'string') { return fp; }108}109}110111// apply_patch encodes file paths in the patch input text112if (toolName === 'apply_patch' && typeof args.input === 'string') {113return extractFirstFileFromPatch(args.input);114}115116return undefined;117}118119/**120* Extract the first file path from an apply_patch input string.121* Matches lines like `*** Update File: /path/to/file` or `*** Add File: /path`.122*/123function extractFirstFileFromPatch(input: string): string | undefined {124const match = input.match(/^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$/m);125return match?.[1]?.trim();126}127128/**129* Safely extract a string field from an unknown object.130*/131function getStringField(obj: unknown, field: string): string | undefined {132if (typeof obj !== 'object' || obj === null) { return undefined; }133const val = (obj as Record<string, unknown>)[field];134return typeof val === 'string' ? val : undefined;135}136137/**138* Safely extract a number field from an unknown object.139*/140function getNumberField(obj: unknown, field: string): number | undefined {141if (typeof obj !== 'object' || obj === null) { return undefined; }142const val = (obj as Record<string, unknown>)[field];143return typeof val === 'number' ? val : undefined;144}145146/**147* Extract refs from GitHub MCP server tool calls.148* These tools use structured args with owner/repo/pullNumber/issue_number/sha etc.149* @internal Exported for testing.150*/151export function extractRefsFromMcpTool(152toolName: string,153toolArgs: unknown,154): Array<{ ref_type: 'pr' | 'issue' | 'commit'; ref_value: string }> {155const refs: Array<{ ref_type: 'pr' | 'issue' | 'commit'; ref_value: string }> = [];156157// PR tools: pull_request_read, list_pull_requests, search_pull_requests158if (toolName.includes('pull_request')) {159const pullNumber = getNumberField(toolArgs, 'pullNumber');160if (pullNumber) {161refs.push({ ref_type: 'pr', ref_value: String(pullNumber) });162}163}164165// Issue tools: issue_read, list_issues, search_issues166if (toolName.includes('issue')) {167const issueNumber = getNumberField(toolArgs, 'issue_number');168if (issueNumber) {169refs.push({ ref_type: 'issue', ref_value: String(issueNumber) });170}171}172173// Commit tools: get_commit, list_commits174if (toolName.includes('commit')) {175const sha = getStringField(toolArgs, 'sha');176if (sha) {177refs.push({ ref_type: 'commit', ref_value: sha });178}179}180181return refs;182}183184/**185* Detect git/gh commands in terminal tool arguments and extract refs from the result.186* @internal Exported for testing.187*/188export function extractRefsFromTerminal(189toolArgs: unknown,190resultText: string | undefined,191): Array<{ ref_type: 'pr' | 'issue' | 'commit'; ref_value: string }> {192const command = getStringField(toolArgs, 'command');193if (!command) { return []; }194195const refs: Array<{ ref_type: 'pr' | 'issue' | 'commit'; ref_value: string }> = [];196197// Detect PR creation/checkout/view/merge — look for PR URL in result198if (/\bgh\s+pr\s+(create|checkout|view|merge)\b/.test(command) && resultText) {199const prMatch = resultText.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);200if (prMatch?.[1]) {201refs.push({ ref_type: 'pr', ref_value: prMatch[1] });202}203}204205// Detect issue creation — look for issue URL in result206if (command.includes('gh issue create') && resultText) {207const issueMatch = resultText.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);208if (issueMatch?.[1]) {209refs.push({ ref_type: 'issue', ref_value: issueMatch[1] });210}211}212213// Detect git commit — extract SHA from "[branch sha]" pattern in output214if (/\bgit\s+commit\b/.test(command) && resultText) {215const commitMatch = resultText.match(/\[[\w/.-]+\s+([0-9a-f]{7,40})\]/);216if (commitMatch?.[1]) {217refs.push({ ref_type: 'commit', ref_value: commitMatch[1] });218}219}220221return refs;222}223224/**225* Extract repository info from GitHub MCP tool args (most tools have owner + repo).226* @internal Exported for testing.227*/228export function extractRepoFromMcpTool(toolArgs: unknown): string | undefined {229const owner = getStringField(toolArgs, 'owner');230const repo = getStringField(toolArgs, 'repo');231if (owner && repo) { return `${owner}/${repo}`; }232return undefined;233}234235/**236* Check whether a tool name is a GitHub MCP server tool.237* Matches both VS Code-style `mcp_github_*` and CLI-style `github-mcp-server-*` prefixes.238*/239export function isGitHubMcpTool(toolName: string): boolean {240return GH_MCP_PREFIXES.some(prefix => toolName.startsWith(prefix));241}242243/** Truncation suffix appended by truncateForOTel. */244const OTEL_TRUNCATION_MARKER = '...[truncated';245246/**247* Extract assistant response text from the gen_ai.output.messages span attribute.248* Handles both valid JSON and truncated JSON (where truncateForOTel cut the249* JSON structure mid-string and appended a suffix).250*251* Expected format: [{"role":"assistant","parts":[{"type":"text","content":"..."}]}]252*253* @internal Exported for testing.254*/255export function extractAssistantResponse(outputMessagesRaw: string | undefined): string | undefined {256if (!outputMessagesRaw) {257return undefined;258}259260// Fast path: try full JSON parse for non-truncated input261try {262const messages = JSON.parse(outputMessagesRaw) as { role: string; parts: { type: string; content: string }[] }[];263const parts = messages264.filter(m => m.role === 'assistant')265.flatMap(m => m.parts)266.filter(p => p.type === 'text')267.map(p => p.content);268return parts.length > 0 ? parts.join('\n') : undefined;269} catch {270// JSON parse failed — likely truncated by truncateForOTel271}272273// Fallback: extract text from truncated JSON by matching the serialized274// assistant text-part prefix, then reading until the truncation marker.275if (!outputMessagesRaw.includes(OTEL_TRUNCATION_MARKER)) {276return undefined;277}278const assistantTextContentPrefix = '"type":"text","content":"';279const prefixStart = outputMessagesRaw.indexOf(assistantTextContentPrefix);280if (prefixStart === -1) {281return undefined;282}283const textStart = prefixStart + assistantTextContentPrefix.length;284const truncationIdx = outputMessagesRaw.indexOf(OTEL_TRUNCATION_MARKER, textStart);285if (truncationIdx === -1) {286return undefined;287}288const extracted = outputMessagesRaw.slice(textStart, truncationIdx);289if (extracted.length === 0) {290return undefined;291}292// The extracted text is JSON-escaped (e.g. \" \n \\). Unescape by wrapping293// in quotes and parsing as a JSON string value.294try {295return JSON.parse(`"${extracted}"`) as string;296} catch {297// If unescape fails (e.g. truncation mid-escape), return the raw text298return extracted;299}300}301302303