Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

436 lines
11 KiB
Go

package parser
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/tidwall/gjson"
)
// parseSession parses a Qwen Code JSONL chat transcript.
//
// Qwen emits one `type=assistant` line per model output, including
// every tool-call iteration in a multi-step turn. Each iteration's
// `message.parts` typically contains only a thought + a functionCall,
// with the final iteration carrying the user-facing text. Counting
// every iteration as a distinct message inflates MessageCount well
// beyond what other agents report for the same turn. To keep counts
// comparable across agents, consecutive tool-call-only assistant
// entries are coalesced into the next text-bearing assistant entry,
// aggregating their thinking text and token usage. A trailing run of
// tool-call-only entries with no text follow-up is emitted as a single
// coalesced assistant message so the data isn't lost.
func parseQwenSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
info, err := os.Stat(path)
if err != nil {
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
}
f, err := os.Open(path)
if err != nil {
return nil, nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
lr := newLineReader(f, maxLineSize)
defer releaseLineReader(lr)
var (
sessionID string
cwd string
firstMessage string
startedAt time.Time
endedAt time.Time
ordinal int
userCount int
messages []ParsedMessage
pending qwenAssistantBuffer
)
flushPending := func() {
if msg, ok := pending.flush(ordinal); ok {
messages = append(messages, msg)
ordinal++
}
}
for {
line, ok := lr.next()
if !ok {
break
}
if !gjson.Valid(line) {
continue
}
root := gjson.Parse(line)
if sessionID == "" {
sessionID = root.Get("sessionId").Str
}
if cwd == "" {
cwd = root.Get("cwd").Str
}
ts := parseTimestamp(root.Get("timestamp").Str)
if !ts.IsZero() {
if startedAt.IsZero() || ts.Before(startedAt) {
startedAt = ts
}
if endedAt.IsZero() || ts.After(endedAt) {
endedAt = ts
}
}
switch root.Get("type").Str {
case "user":
if root.Get("message.role").Str != "user" {
continue
}
parts := root.Get("message.parts")
content := qwenJoinedParts(parts, false)
toolResults := qwenExtractToolResults(parts)
if strings.TrimSpace(content) == "" {
// Tool-result-only user entries are part of the
// surrounding assistant turn; fold them into the
// pending buffer so the coalesced turn carries its
// own ToolResults without breaking message ordering.
if len(toolResults) > 0 {
pending.absorbToolResults(toolResults)
}
continue
}
// A new user text turn closes any pending tool-call-only run.
flushPending()
if firstMessage == "" {
firstMessage = truncate(
strings.ReplaceAll(content, "\n", " "),
300,
)
}
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleUser,
Content: content,
Timestamp: ts,
ContentLength: len(content),
ToolResults: toolResults,
})
ordinal++
userCount++
case "assistant":
if root.Get("message.role").Str != "model" {
continue
}
parts := root.Get("message.parts")
content := qwenJoinedParts(parts, false)
thinking := qwenJoinedParts(parts, true)
hasThinking := qwenHasThought(parts)
toolCalls := qwenExtractToolCalls(parts)
if strings.TrimSpace(content) == "" && !hasThinking &&
len(toolCalls) == 0 {
continue
}
pending.absorb(qwenAssistantEntry{
content: content,
thinking: thinking,
hasThinking: hasThinking,
toolCalls: toolCalls,
timestamp: ts,
model: root.Get("model").Str,
usage: root.Get("usageMetadata"),
})
// Only flush on a "closing" entry: text with no tool call.
// An entry that carries both text and a functionCall is an
// intermediate iteration awaiting its tool result; flushing
// here would orphan the upcoming functionResponse onto a new
// pending assistant turn and inflate MessageCount.
if strings.TrimSpace(content) != "" && len(toolCalls) == 0 {
flushPending()
}
}
}
flushPending()
if err := lr.Err(); err != nil {
return nil, nil, fmt.Errorf("reading qwen %s: %w", path, err)
}
if sessionID == "" {
sessionID = strings.TrimSuffix(filepath.Base(path), ".jsonl")
}
if project == "" && cwd != "" {
project = ExtractProjectFromCwd(cwd)
}
sess := &ParsedSession{
ID: "qwen:" + sessionID,
Project: project,
Machine: machine,
Agent: AgentQwen,
Cwd: cwd,
FirstMessage: firstMessage,
StartedAt: startedAt,
EndedAt: endedAt,
MessageCount: len(messages),
UserMessageCount: userCount,
File: FileInfo{
Path: path,
Size: info.Size(),
Mtime: info.ModTime().UnixNano(),
},
}
accumulateMessageTokenUsage(sess, messages)
return sess, messages, nil
}
func qwenJoinedParts(parts gjson.Result, thoughtsOnly bool) string {
if !parts.IsArray() {
return ""
}
var textParts []string
parts.ForEach(func(_, part gjson.Result) bool {
isThought := part.Get("thought").Bool()
if isThought != thoughtsOnly {
return true
}
text := part.Get("text").Str
if text != "" {
textParts = append(textParts, text)
}
return true
})
return strings.Join(textParts, "\n")
}
func qwenHasThought(parts gjson.Result) bool {
if !parts.IsArray() {
return false
}
hasThought := false
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("thought").Bool() {
hasThought = true
return false
}
return true
})
return hasThought
}
// qwenExtractToolCalls pulls functionCall parts out of a Qwen
// `message.parts` array into ParsedToolCall entries. Qwen uses the
// Gemini-style shape: {"functionCall": {"id": ..., "name": ..., "args": {...}}}.
func qwenExtractToolCalls(parts gjson.Result) []ParsedToolCall {
if !parts.IsArray() {
return nil
}
var calls []ParsedToolCall
parts.ForEach(func(_, part gjson.Result) bool {
fc := part.Get("functionCall")
if !fc.Exists() {
return true
}
name := fc.Get("name").Str
if name == "" {
return true
}
calls = append(calls, ParsedToolCall{
ToolUseID: fc.Get("id").Str,
ToolName: name,
Category: NormalizeToolCategory(name),
InputJSON: fc.Get("args").Raw,
})
return true
})
return calls
}
// qwenExtractToolResults pulls functionResponse parts out of a Qwen
// user `message.parts` array into ParsedToolResult entries. Qwen emits
// tool results as user messages with parts like
// {"functionResponse": {"id": ..., "name": ..., "response": {"output": ...}}}.
// The typical "output" payload is unwrapped so the shared content
// decoders (which expect a string, array, or iFlow-nested object) can
// surface the result text; less common response shapes fall back to
// the raw response object.
func qwenExtractToolResults(parts gjson.Result) []ParsedToolResult {
if !parts.IsArray() {
return nil
}
var results []ParsedToolResult
parts.ForEach(func(_, part gjson.Result) bool {
fr := part.Get("functionResponse")
if !fr.Exists() {
return true
}
content := fr.Get("response.output")
if !content.Exists() {
content = fr.Get("response")
}
results = append(results, ParsedToolResult{
ToolUseID: fr.Get("id").Str,
ContentLength: toolResultContentLength(content),
ContentRaw: content.Raw,
})
return true
})
return results
}
// qwenAssistantEntry holds the fields extracted from a single
// `type=assistant` JSONL line, ready to be folded into a turn buffer.
type qwenAssistantEntry struct {
content string
thinking string
hasThinking bool
toolCalls []ParsedToolCall
timestamp time.Time
model string
usage gjson.Result
}
// qwenAssistantBuffer accumulates one logical assistant turn across
// one or more JSONL entries. Tool-call-only iterations contribute
// thinking, tool calls, and token usage; the closing text-bearing
// entry (or EOF / next user text turn) flushes the buffer to a single
// ParsedMessage. Intermediate tool-result user entries fold their
// ParsedToolResult entries into the same buffer so the coalesced turn
// retains its tool-use evidence.
type qwenAssistantBuffer struct {
pending bool
content string
thinking []string
hasThinking bool
toolCalls []ParsedToolCall
toolResults []ParsedToolResult
timestamp time.Time
model string
sumOutput int
sumUncached int
sumCacheRead int
hasOutput bool
peakPrompt int
hasContext bool
}
func (b *qwenAssistantBuffer) absorb(e qwenAssistantEntry) {
b.pending = true
if e.content != "" {
if b.content != "" {
b.content += "\n" + e.content
} else {
b.content = e.content
}
}
if e.thinking != "" {
b.thinking = append(b.thinking, e.thinking)
}
if e.hasThinking {
b.hasThinking = true
}
if len(e.toolCalls) > 0 {
b.toolCalls = append(b.toolCalls, e.toolCalls...)
}
if !e.timestamp.IsZero() {
b.timestamp = e.timestamp
}
if e.model != "" {
b.model = e.model
}
if !e.usage.Exists() {
return
}
inputField := e.usage.Get("promptTokenCount")
outputField := e.usage.Get("candidatesTokenCount")
cacheReadField := e.usage.Get("cachedContentTokenCount")
if !inputField.Exists() && !outputField.Exists() &&
!cacheReadField.Exists() {
return
}
// Qwen reports promptTokenCount as the full input count with the
// cached portion already included (totalTokenCount = prompt +
// candidates). For a turn coalesced from multiple model calls,
// normalized input/cache usage sums the per-call uncached and cache-
// read counts so we report total tokens billed across iterations.
// ContextTokens stays at the peak prompt to reflect the largest
// single-call context window (cached + uncached) without double-
// counting cached tokens across calls.
prompt := int(inputField.Int())
output := int(outputField.Int())
cacheRead := int(cacheReadField.Int())
if outputField.Exists() {
b.hasOutput = true
b.sumOutput += output
}
if inputField.Exists() || cacheReadField.Exists() {
b.hasContext = true
b.sumUncached += max(prompt-cacheRead, 0)
b.sumCacheRead += cacheRead
if prompt > b.peakPrompt {
b.peakPrompt = prompt
}
}
}
func (b *qwenAssistantBuffer) absorbToolResults(trs []ParsedToolResult) {
if len(trs) == 0 {
return
}
b.pending = true
b.toolResults = append(b.toolResults, trs...)
}
func (b *qwenAssistantBuffer) flush(ordinal int) (ParsedMessage, bool) {
if !b.pending {
return ParsedMessage{}, false
}
msg := ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Content: b.content,
ThinkingText: strings.Join(b.thinking, "\n"),
Timestamp: b.timestamp,
HasThinking: b.hasThinking,
HasToolUse: len(b.toolCalls) > 0,
ContentLength: len(b.content),
Model: b.model,
ToolCalls: b.toolCalls,
ToolResults: b.toolResults,
}
if b.hasOutput || b.hasContext {
normalized := map[string]int{
"input_tokens": b.sumUncached,
"output_tokens": b.sumOutput,
"cache_read_input_tokens": b.sumCacheRead,
}
if j, err := json.Marshal(normalized); err == nil {
msg.TokenUsage = j
}
msg.OutputTokens = b.sumOutput
msg.HasOutputTokens = b.hasOutput
msg.ContextTokens = b.peakPrompt
msg.HasContextTokens = b.hasContext
}
*b = qwenAssistantBuffer{}
return msg, true
}