File size: 8,168 Bytes
7c5ed63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
// Aggregate every "something went wrong" signal from a wire timeline
// into a flat list consumable by the Issues drawer. Pure — no React.
//
// Detection rules for the engine's wire protocol:
//   - tool.call without paired tool.result (orphan tool.call)
//   - tool.result without preceding tool.call (orphan tool.result)
//   - tool.result with isError (tool failed)
//   - tool.result with truncated output (model saw partial output)
//   - step.end finishReason 'filtered' (provider blocked the response)
//   - step.end finishReason 'max_tokens' (response cut at the output cap)
//   - step.begin without paired step.end (incomplete step)
//   - full_compaction.begin without complete/cancel (incomplete compaction)
//   - plan_mode.enter without exit/cancel (still in plan mode)
//   - permission.record_approval_result with decision='rejected' (info)
//
// Wire-file parse warnings are appended as info-level entries with no lineNo.

import type { WireEntry } from '../types';

export type IssueSeverity = 'error' | 'warning' | 'info';

export type IssueKind =
  | 'orphan_tool_call'
  | 'missing_tool_result'
  | 'tool_error'
  | 'tool_truncated'
  | 'model_filtered'
  | 'model_max_tokens'
  | 'incomplete_step'
  | 'incomplete_compaction'
  | 'active_plan_mode'
  | 'rejected_approval'
  | 'wire_warning';

export interface Issue {
  severity: IssueSeverity;
  kind: IssueKind;
  /** Line number of the offending record. `null` for file-level warnings. */
  lineNo: number | null;
  /** Short summary shown on a single line. */
  summary: string;
  /** Optional second line / tooltip detail. */
  detail?: string;
}

const SEVERITY_ORDER: Record<IssueSeverity, number> = {
  error: 0,
  warning: 1,
  info: 2,
};

/** Scan `records` + `warnings` and produce an ordered issue list.
 *  Sorted by severity first, then lineNo ascending. Warnings (no lineNo) go last. */
export function computeIssues(
  entries: readonly WireEntry[],
  warnings: readonly string[],
): Issue[] {
  const out: Issue[] = [];

  // Track in-flight tool calls keyed by toolCallId, step begins by uuid,
  // compaction begin lineNo, and plan mode enter id.
  const toolCallById = new Map<string, { lineNo: number; name: string }>();
  const stepBeginByUuid = new Map<string, { lineNo: number; step: number; turnId: string }>();
  let lastCompactionBegin: { lineNo: number; source: string } | null = null;
  let lastPlanEnter: { lineNo: number; id: string } | null = null;

  for (const entry of entries) {
    const r = entry.data;
    const lineNo = entry.lineNo;
    switch (r.type) {
      case 'context.append_loop_event': {
        const ev = r.event;
        if (ev.type === 'tool.call') {
          // New in-flight tool call.
          toolCallById.set(ev.toolCallId, { lineNo, name: ev.name });
        } else if (ev.type === 'tool.result') {
          const open = toolCallById.get(ev.toolCallId);
          if (open !== undefined) {
            toolCallById.delete(ev.toolCallId);
          } else {
            out.push({
              severity: 'warning',
              kind: 'missing_tool_result',
              lineNo,
              summary: `orphan tool.result for #${ev.toolCallId.slice(-8)}`,
              detail: 'no preceding tool.call seen',
            });
          }
          // Runtime failure / partial-output signals carried on the result.
          // v1 persisted `truncated` / `message`; v2 persists `note` instead.
          const result = ev.result as { truncated?: boolean; message?: string; note?: string };
          if (ev.result.isError === true) {
            out.push({
              severity: 'error',
              kind: 'tool_error',
              lineNo,
              summary: `${open?.name ?? 'tool'}#${ev.toolCallId.slice(-8)} returned an error`,
              detail: result.message ?? result.note,
            });
          }
          if (result.truncated === true) {
            out.push({
              severity: 'info',
              kind: 'tool_truncated',
              lineNo,
              summary: `${open?.name ?? 'tool'}#${ev.toolCallId.slice(-8)} output truncated`,
              detail: 'the model saw a paged/dropped partial result',
            });
          }
        } else if (ev.type === 'step.begin') {
          stepBeginByUuid.set(ev.uuid, {
            lineNo,
            // `step` / `turnId` are optional on v2 loop events.
            step: ev.step ?? -1,
            turnId: ev.turnId ?? '',
          });
        } else if (ev.type === 'step.end') {
          stepBeginByUuid.delete(ev.uuid);
          if (ev.finishReason === 'filtered') {
            out.push({
              severity: 'error',
              kind: 'model_filtered',
              lineNo,
              summary: `step ${ev.step} response filtered by the provider`,
              detail: ev.rawFinishReason ?? ev.providerFinishReason,
            });
          } else if (ev.finishReason === 'max_tokens') {
            out.push({
              severity: 'warning',
              kind: 'model_max_tokens',
              lineNo,
              summary: `step ${ev.step} hit the output token cap`,
              detail: 'the response was cut short at max_tokens',
            });
          }
        }
        break;
      }

      case 'full_compaction.begin':
        lastCompactionBegin = { lineNo, source: r.source };
        break;
      case 'full_compaction.complete':
      case 'full_compaction.cancel':
        lastCompactionBegin = null;
        break;

      case 'plan_mode.enter':
        lastPlanEnter = { lineNo, id: r.id };
        break;
      case 'plan_mode.cancel':
      case 'plan_mode.exit':
        lastPlanEnter = null;
        break;

      case 'permission.record_approval_result':
        if (r.result.decision === 'rejected') {
          out.push({
            severity: 'info',
            kind: 'rejected_approval',
            lineNo,
            summary: `${r.toolName}#${r.toolCallId.slice(-8)} rejected`,
            detail: r.result.feedback,
          });
        }
        break;

      default:
        break;
    }
  }

  // Drain unmatched in-flight entries.
  for (const [id, info] of toolCallById) {
    out.push({
      severity: 'warning',
      kind: 'orphan_tool_call',
      lineNo: info.lineNo,
      summary: `${info.name}#${id.slice(-8)} has no tool.result`,
      detail: 'tool.call recorded but no matching tool.result found',
    });
  }
  for (const [uuid, info] of stepBeginByUuid) {
    out.push({
      severity: 'warning',
      kind: 'incomplete_step',
      lineNo: info.lineNo,
      summary: `step ${info.step} (turn ${info.turnId}) has no step.end`,
      detail: `uuid ${uuid.slice(-8)}`,
    });
  }
  if (lastCompactionBegin !== null) {
    out.push({
      severity: 'warning',
      kind: 'incomplete_compaction',
      lineNo: lastCompactionBegin.lineNo,
      summary: `${lastCompactionBegin.source} compaction never completed`,
    });
  }
  if (lastPlanEnter !== null) {
    out.push({
      severity: 'info',
      kind: 'active_plan_mode',
      lineNo: lastPlanEnter.lineNo,
      summary: `plan mode still active: ${lastPlanEnter.id}`,
    });
  }

  for (const w of warnings) {
    out.push({
      severity: 'info',
      kind: 'wire_warning',
      lineNo: null,
      summary: firstLine(w),
    });
  }

  out.sort((a, b) => {
    const d = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
    if (d !== 0) return d;
    const sa = a.lineNo ?? Number.POSITIVE_INFINITY;
    const sb = b.lineNo ?? Number.POSITIVE_INFINITY;
    return sa - sb;
  });

  return out;
}

/** Top-level summary tone used for the toolbar pill — "worst wins". */
export function topSeverity(issues: readonly Issue[]): IssueSeverity | null {
  if (issues.length === 0) return null;
  for (const i of issues) if (i.severity === 'error') return 'error';
  for (const i of issues) if (i.severity === 'warning') return 'warning';
  return 'info';
}

function firstLine(s: string): string {
  const trimmed = s.trim();
  const nl = trimmed.indexOf('\n');
  const one = nl === -1 ? trimmed : trimmed.slice(0, nl);
  return one.length > 120 ? one.slice(0, 120) + '…' : one;
}