/ libs / typescript / integrations / qwen-code / src / transcript.ts
transcript.ts
  1  /**
  2   * Transcript parsing utilities for Qwen Code JSONL files.
  3   *
  4   * Qwen Code writes chat records to
  5   * `~/.qwen/projects/<project-id>/chats/<sessionId>.jsonl`. Records have
  6   * `uuid`/`parentUuid` for tree traversal but are also emitted in
  7   * chronological order, so we walk them sequentially for span creation and
  8   * use the tree only for message history reconstruction.
  9   */
 10  
 11  import { readFileSync } from 'node:fs';
 12  import type {
 13    ChatRecord,
 14    FunctionCall,
 15    FunctionCallPart,
 16    FunctionResponsePart,
 17    GeminiMessage,
 18    GeminiPart,
 19    TextPart,
 20    UsageMetadata,
 21  } from './types.js';
 22  
 23  export const NANOSECONDS_PER_MS = 1e6;
 24  
 25  /** Read and parse a Qwen Code JSONL transcript file. */
 26  export function readTranscript(path: string): ChatRecord[] {
 27    const content = readFileSync(path, 'utf-8');
 28    return content
 29      .split('\n')
 30      .filter((line) => line.trim())
 31      .map((line) => JSON.parse(line) as ChatRecord);
 32  }
 33  
 34  /** Parse an ISO timestamp string to nanoseconds since Unix epoch. */
 35  export function parseTimestampToNs(timestamp: string | undefined | null): number | null {
 36    if (!timestamp) {
 37      return null;
 38    }
 39    try {
 40      const ms = new Date(timestamp).getTime();
 41      if (isNaN(ms)) {
 42        return null;
 43      }
 44      return ms * NANOSECONDS_PER_MS;
 45    } catch {
 46      return null;
 47    }
 48  }
 49  
 50  /**
 51   * Narrow a GeminiPart union member. The runtime shape is discriminated by
 52   * the presence of `text` / `functionCall` / `functionResponse` keys.
 53   */
 54  export function isTextPart(part: GeminiPart): part is TextPart {
 55    return typeof (part as TextPart).text === 'string';
 56  }
 57  
 58  export function isFunctionCallPart(part: GeminiPart): part is FunctionCallPart {
 59    return (part as FunctionCallPart).functionCall != null;
 60  }
 61  
 62  export function isFunctionResponsePart(part: GeminiPart): part is FunctionResponsePart {
 63    return (part as FunctionResponsePart).functionResponse != null;
 64  }
 65  
 66  /**
 67   * Extract user-facing text from a record's message. Internal reasoning
 68   * (`thought: true` text parts) is excluded — those should not appear as
 69   * assistant content in the Chat view.
 70   *
 71   * If `includeThoughts` is true, thought parts are included (used only by
 72   * request-preview fallbacks where there's nothing else to show).
 73   */
 74  export function getMessageText(record: ChatRecord, includeThoughts = false): string {
 75    const msg = record.message;
 76    if (typeof msg === 'string') {
 77      return msg;
 78    }
 79    if (!isGeminiMessage(msg)) {
 80      return '';
 81    }
 82    return msg.parts
 83      .filter(isTextPart)
 84      .filter((p) => includeThoughts || !p.thought)
 85      .map((p) => p.text)
 86      .join('\n');
 87  }
 88  
 89  /** Return the functionCall parts embedded in an assistant record's message. */
 90  export function getFunctionCalls(record: ChatRecord): FunctionCall[] {
 91    const msg = record.message;
 92    if (!isGeminiMessage(msg)) {
 93      return [];
 94    }
 95    return msg.parts.filter(isFunctionCallPart).map((p) => p.functionCall);
 96  }
 97  
 98  function isGeminiMessage(msg: unknown): msg is GeminiMessage {
 99    return (
100      typeof msg === 'object' &&
101      msg != null &&
102      'parts' in msg &&
103      Array.isArray((msg as GeminiMessage).parts)
104    );
105  }
106  
107  /**
108   * Return the records belonging to the last turn (from the last user record
109   * through end-of-file), in chronological order.
110   */
111  export function getLastTurnRecords(records: ChatRecord[]): ChatRecord[] {
112    for (let i = records.length - 1; i >= 0; i--) {
113      if (records[i].type === 'user' && getMessageText(records[i]).trim()) {
114        return records.slice(i);
115      }
116    }
117    return [];
118  }
119  
120  /** Build a lookup from tool call_id to its `tool_result` record. */
121  export function buildToolResultMap(records: ChatRecord[]): Map<string, ChatRecord> {
122    const byCallId = new Map<string, ChatRecord>();
123    for (const record of records) {
124      if (record.type === 'tool_result' && record.toolCallResult?.callId) {
125        byCallId.set(record.toolCallResult.callId, record);
126      }
127    }
128    return byCallId;
129  }
130  
131  /** Extract structured token usage from a record's usageMetadata, if any. */
132  export function getTokenUsage(
133    metadata: UsageMetadata | undefined,
134  ): { input: number; output: number; total: number } | null {
135    if (!metadata) {
136      return null;
137    }
138    const input = metadata.promptTokenCount ?? metadata.input_tokens ?? 0;
139    const output = metadata.candidatesTokenCount ?? metadata.output_tokens ?? 0;
140    const total = metadata.totalTokenCount ?? input + output;
141    if (input === 0 && output === 0) {
142      return null;
143    }
144    return { input, output, total };
145  }
146  
147  /**
148   * Normalize a `resultDisplay` value (which can be a plain string or a
149   * structured object) into a string suitable for a TOOL span output or a
150   * tool message's content.
151   */
152  export function formatResultDisplay(display: unknown): string {
153    if (display == null) {
154      return '';
155    }
156    if (typeof display === 'string') {
157      return display;
158    }
159    return JSON.stringify(display);
160  }
161  
162  /**
163   * Return a string rendering of a tool_result record's output. Prefers
164   * `toolCallResult.resultDisplay` (the user-facing rendering) and falls back
165   * to the raw `functionResponse.response` payload embedded in `message.parts`
166   * when `resultDisplay` is omitted. Returns an empty string if neither is
167   * available.
168   */
169  export function getToolOutput(record: ChatRecord): string {
170    const display = record.toolCallResult?.resultDisplay;
171    if (display != null) {
172      return formatResultDisplay(display);
173    }
174    const msg = record.message;
175    if (isGeminiMessage(msg)) {
176      for (const part of msg.parts) {
177        if (isFunctionResponsePart(part) && part.functionResponse.response != null) {
178          return formatResultDisplay(part.functionResponse.response);
179        }
180      }
181    }
182    return '';
183  }