Skip to content

Commit 0660137

Browse files
wenytang-msCopilot
andcommitted
Address Copilot review on PR #1644
- classifyStep: unknown step operations now report 'unknown' instead of being silently mislabeled as 'over'. Also adds a runtime guard in debug_step_operation so an unknown operation no longer reaches commandMap[op]/executeCommand(undefined) or session.customRequest with an arbitrary string. - recordToolInvocation: introduces a private normalizeToolInvocationRecord that keeps 'outcome' and 'errorCategory' in lock-step for the six shared terminal values (cancelled / timeout / lsNotReady / noActiveSession / noSuspendedThread / noStackFrame). Fixes the case where debug_java_application returns {success:false,message:'Operation cancelled by user'} but outcome was 'failure' while errorCategory was 'cancelled'. - get_debug_stack_trace: empty-stack-frame early return now sets errorCategory='noStackFrame' alongside outcome (was only setting outcome). - recordLaunchInternal: signature is now a discriminated union (LaunchInternalEvent) instead of (operationName: string, properties: Record<string, SafeValue>). Unknown event names and unexpected property keys are now rejected at compile time. Updated all 8 call sites. - elapsedTime (string from .toFixed) split from elapsedMs (number) so the telemetry value is numeric and aggregable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 516ac8f commit 0660137

2 files changed

Lines changed: 132 additions & 38 deletions

File tree

src/languageModelTool.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ async function debugJavaApplication(
149149
// Step 0: Cleanup any existing Java debug session to avoid port conflicts
150150
const existingSession = vscode.debug.activeDebugSession;
151151
if (existingSession && existingSession.type === 'java') {
152-
recordLaunchInternal('cleanupExistingSession', {
152+
recordLaunchInternal({
153+
name: 'cleanupExistingSession',
153154
sessionId: existingSession.id,
154155
});
155156
try {
@@ -158,7 +159,8 @@ async function debugJavaApplication(
158159
await new Promise(resolve => setTimeout(resolve, 500));
159160
} catch (error) {
160161
// Log but continue - the old session might already be dead
161-
recordLaunchInternal('cleanupExistingSessionFailed', {
162+
recordLaunchInternal({
163+
name: 'cleanupExistingSessionFailed',
162164
errorCategory: classifyError(error),
163165
});
164166
}
@@ -245,7 +247,8 @@ async function debugJavaApplication(
245247
clearTimeout(timeoutHandle);
246248
}
247249

248-
recordLaunchInternal('debugSessionStarted.eventBased', {
250+
recordLaunchInternal({
251+
name: 'debugSessionStarted.eventBased',
249252
sessionId: session.id,
250253
});
251254

@@ -267,7 +270,7 @@ async function debugJavaApplication(
267270
if (!sessionStarted) {
268271
sessionDisposable.dispose();
269272

270-
recordLaunchInternal('debugSessionTimeout.eventBased', {});
273+
recordLaunchInternal({ name: 'debugSessionTimeout.eventBased' });
271274

272275
resolve({
273276
success: false,
@@ -301,11 +304,13 @@ async function debugJavaApplication(
301304
// Check if debug session has started
302305
const session = vscode.debug.activeDebugSession;
303306
if (session && session.type === 'java') {
304-
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1);
307+
const elapsedMs = Date.now() - startTime;
308+
const elapsedTime = (elapsedMs / 1000).toFixed(1);
305309

306-
recordLaunchInternal('debugSessionDetected', {
310+
recordLaunchInternal({
311+
name: 'debugSessionDetected',
307312
sessionId: session.id,
308-
elapsedTime,
313+
elapsedMs,
309314
});
310315

311316
return {
@@ -322,7 +327,8 @@ async function debugJavaApplication(
322327
}
323328

324329
// Timeout: session not detected within 15 seconds
325-
recordLaunchInternal('debugSessionTimeout.smartPolling', {
330+
recordLaunchInternal({
331+
name: 'debugSessionTimeout.smartPolling',
326332
maxWaitTime,
327333
});
328334

@@ -602,14 +608,16 @@ function constructDebugCommand(
602608
if (!input.target.includes('.')) {
603609
const detectedClassName = findFullyQualifiedClassName(input.workspacePath, input.target, projectType);
604610
if (detectedClassName) {
605-
recordLaunchInternal('classNameDetection', {
611+
recordLaunchInternal({
612+
name: 'classNameDetection',
606613
projectType,
607614
detected: true,
608615
});
609616
className = detectedClassName;
610617
} else {
611618
// No package detected - class is in default package
612-
recordLaunchInternal('classNameDetection', {
619+
recordLaunchInternal({
620+
name: 'classNameDetection',
613621
projectType,
614622
detected: false,
615623
});
@@ -1012,6 +1020,14 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs
10121020
};
10131021

10141022
const command = commandMap[operation];
1023+
if (!command) {
1024+
outcome = 'failure';
1025+
errorCategory = 'other';
1026+
return new (vscode as any).LanguageModelToolResult([
1027+
new (vscode as any).LanguageModelTextPart(`✗ Unknown step operation: ${operation}`)
1028+
]);
1029+
}
1030+
10151031
if (threadId !== undefined) {
10161032
// For thread-specific operations, use custom request
10171033
await session.customRequest(operation, { threadId });
@@ -1181,6 +1197,7 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs
11811197

11821198
if (!stackResponse.stackFrames || stackResponse.stackFrames.length === 0) {
11831199
outcome = 'noStackFrame';
1200+
errorCategory = 'noStackFrame';
11841201
return new (vscode as any).LanguageModelToolResult([
11851202
new (vscode as any).LanguageModelTextPart('No stack frames available.')
11861203
]);
@@ -1625,7 +1642,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs
16251642
// If we can't even get threads, something is wrong
16261643
// But session exists, so mark as running
16271644
isPaused = false;
1628-
recordLaunchInternal('getDebugSessionInfo.threadError', {
1645+
recordLaunchInternal({
1646+
name: 'getDebugSessionInfo.threadError',
16291647
errorCategory: classifyError(error),
16301648
});
16311649
}

src/lmToolTelemetry.ts

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export type BreakpointKind =
7676
| 'hitCount'
7777
| 'logpoint';
7878

79-
export type StepKind = 'in' | 'out' | 'over' | 'continue' | 'pause';
79+
export type StepKind = 'in' | 'out' | 'over' | 'continue' | 'pause' | 'unknown';
8080

8181
export type EvalContext = 'watch' | 'repl' | 'hover' | 'unknown';
8282

@@ -215,7 +215,7 @@ export function classifyStep(operation: string | undefined): StepKind {
215215
case 'pause':
216216
return 'pause';
217217
default:
218-
return 'over';
218+
return 'unknown';
219219
}
220220
}
221221

@@ -309,31 +309,83 @@ export interface ToolInvocationRecord {
309309
/**
310310
* Record a single tool-invocation outcome. Replaces ad-hoc `sendInfo`
311311
* calls inside individual tools.
312+
*
313+
* Before sending, the record is normalized so that `outcome` and
314+
* `errorCategory` stay aligned for the six shared terminal values
315+
* (cancelled / timeout / lsNotReady / noActiveSession / noSuspendedThread /
316+
* noStackFrame). See {@link normalizeToolInvocationRecord}.
312317
*/
313318
export function recordToolInvocation(record: ToolInvocationRecord): void {
319+
const normalized = normalizeToolInvocationRecord(record);
314320
sanitizedSend({
315-
operationName: `languageModelTool.${record.tool}.invoke`,
316-
outcome: record.outcome,
317-
errorCategory: record.errorCategory,
318-
durationMs: record.durationMs,
319-
targetType: record.targetType,
320-
breakpointKind: record.breakpointKind,
321-
stepKind: record.stepKind,
322-
evalContext: record.evalContext,
323-
removeScope: record.removeScope,
324-
scopeType: record.scopeType,
325-
isPaused: record.isPaused,
326-
skipBuild: record.skipBuild,
327-
hasFilter: record.hasFilter,
328-
frameCount: record.frameCount,
329-
threadCount: record.threadCount,
330-
suspendedCount: record.suspendedCount,
331-
removedCount: record.removedCount,
332-
sessionId: record.sessionId,
333-
sessionType: record.sessionType,
321+
operationName: `languageModelTool.${normalized.tool}.invoke`,
322+
outcome: normalized.outcome,
323+
errorCategory: normalized.errorCategory,
324+
durationMs: normalized.durationMs,
325+
targetType: normalized.targetType,
326+
breakpointKind: normalized.breakpointKind,
327+
stepKind: normalized.stepKind,
328+
evalContext: normalized.evalContext,
329+
removeScope: normalized.removeScope,
330+
scopeType: normalized.scopeType,
331+
isPaused: normalized.isPaused,
332+
skipBuild: normalized.skipBuild,
333+
hasFilter: normalized.hasFilter,
334+
frameCount: normalized.frameCount,
335+
threadCount: normalized.threadCount,
336+
suspendedCount: normalized.suspendedCount,
337+
removedCount: normalized.removedCount,
338+
sessionId: normalized.sessionId,
339+
sessionType: normalized.sessionType,
334340
});
335341
}
336342

343+
/**
344+
* Values that exist in both {@link ToolOutcome} and {@link ErrorCategory}.
345+
* For these, the two fields must stay in lock-step so dashboard queries
346+
* filtering on either one produce identical results.
347+
*/
348+
const SHARED_TERMINAL_VALUES = [
349+
'cancelled',
350+
'timeout',
351+
'lsNotReady',
352+
'noActiveSession',
353+
'noSuspendedThread',
354+
'noStackFrame',
355+
] as const;
356+
357+
type SharedTerminal = typeof SHARED_TERMINAL_VALUES[number];
358+
359+
function isSharedTerminal(value: string | undefined): value is SharedTerminal {
360+
return value !== undefined && (SHARED_TERMINAL_VALUES as readonly string[]).includes(value);
361+
}
362+
363+
/**
364+
* Reconcile `outcome` and `errorCategory` for the six shared terminal
365+
* values so downstream queries can rely on either field. Returns a NEW
366+
* record; the input is not mutated.
367+
*
368+
* Rules:
369+
* - If `errorCategory` is a shared terminal value, promote `outcome` to
370+
* that value (callers that only set `errorCategory` get a consistent
371+
* `outcome` for free).
372+
* - If `outcome` is a shared terminal value and `errorCategory` is
373+
* absent, fill it with the matching value (callers that only set
374+
* `outcome` get a consistent `errorCategory`).
375+
*/
376+
function normalizeToolInvocationRecord(record: ToolInvocationRecord): ToolInvocationRecord {
377+
let outcome: ToolOutcome = record.outcome;
378+
let errorCategory: ErrorCategory | undefined = record.errorCategory;
379+
380+
if (isSharedTerminal(errorCategory)) {
381+
outcome = errorCategory;
382+
} else if (isSharedTerminal(outcome) && errorCategory === undefined) {
383+
errorCategory = outcome;
384+
}
385+
386+
return { ...record, outcome, errorCategory };
387+
}
388+
337389
export interface ChatActivationRecord {
338390
javaLSReadyAtActivation: boolean;
339391
lmtCount: number;
@@ -358,17 +410,41 @@ export function recordChatActivation(record: ChatActivationRecord): void {
358410
});
359411
}
360412

413+
/**
414+
* Project type detected by the launch flow. Free-form values are
415+
* forbidden so this stays a closed enum.
416+
*/
417+
export type LaunchProjectType = 'maven' | 'gradle' | 'vscode' | 'unknown';
418+
419+
/**
420+
* Discriminated union of every launch-flow internal event the recorder
421+
* is allowed to emit. Each variant lists its allowed properties so the
422+
* type system rejects unknown event names and unknown property keys.
423+
*
424+
* Note: `sessionId` here is VS Code's opaque debug-session GUID, never
425+
* the user-visible `launch.json` session name.
426+
*/
427+
export type LaunchInternalEvent =
428+
| { name: 'cleanupExistingSession'; sessionId: string }
429+
| { name: 'cleanupExistingSessionFailed'; errorCategory: ErrorCategory }
430+
| { name: 'debugSessionStarted.eventBased'; sessionId: string }
431+
| { name: 'debugSessionTimeout.eventBased' }
432+
| { name: 'debugSessionDetected'; sessionId: string; elapsedMs: number }
433+
| { name: 'debugSessionTimeout.smartPolling'; maxWaitTime: number }
434+
| { name: 'classNameDetection'; projectType: LaunchProjectType; detected: boolean }
435+
| { name: 'getDebugSessionInfo.threadError'; errorCategory: ErrorCategory };
436+
361437
/**
362438
* Internal-debug event for the launch-flow nested instrumentation
363439
* (session-detected / cleanup / timeout). Re-uses the sanitised sender so
364-
* no PII can slip in.
440+
* no PII can slip in. Accepts only the discriminated-union shapes defined
441+
* in {@link LaunchInternalEvent} — unknown event names or unexpected
442+
* property keys are rejected at compile time.
365443
*/
366-
export function recordLaunchInternal(
367-
operationName: string,
368-
properties: Record<string, SafeValue>,
369-
): void {
444+
export function recordLaunchInternal(event: LaunchInternalEvent): void {
445+
const { name, ...properties } = event;
370446
sanitizedSend({
371-
operationName: `languageModelTool.${operationName}`,
447+
operationName: `languageModelTool.${name}`,
372448
...properties,
373449
});
374450
}

0 commit comments

Comments
 (0)