f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package parser
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// parseIflowSessionForTest exercises the iFlow provider's transcript parsing
|
|
// in isolation, passing the project directly so tests can assert parse
|
|
// behavior without project-resolution enrichment.
|
|
func parseIflowSessionForTest(
|
|
t *testing.T, path, project, machine string,
|
|
) ([]ParseResult, error) {
|
|
t.Helper()
|
|
return parseIflowSession(path, project, machine)
|
|
}
|
|
|
|
func TestParseIflowSession(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
expectID string
|
|
expectMessageCount int
|
|
expectFirstMessage string
|
|
}{
|
|
{
|
|
name: "basic iFlow session",
|
|
filename: "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
|
|
expectID: "iflow:5de701fc-7454-4858-a249-95cac4fd3b51",
|
|
expectMessageCount: 11,
|
|
expectFirstMessage: "启动app时确保环境变量 DOCKER_API_VERSION=\"1.46\"",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
results, err := parseIflowSessionForTest(
|
|
t,
|
|
tt.filename,
|
|
"test-project",
|
|
"local",
|
|
)
|
|
|
|
require.NoError(t, err, "ParseIflowSession error")
|
|
require.NotEmpty(t, results, "expected at least one result")
|
|
|
|
session := results[0].Session
|
|
assert.Equal(t, tt.expectID, session.ID, "ID")
|
|
assert.Equal(t, AgentIflow, session.Agent, "agent")
|
|
assert.Equal(t, "test-project", session.Project, "project")
|
|
assert.Equal(t, tt.expectMessageCount, session.MessageCount, "message count")
|
|
assert.Len(t, results[0].Messages, tt.expectMessageCount, "parsed messages")
|
|
assert.Equal(t, tt.expectFirstMessage, session.FirstMessage, "first message")
|
|
|
|
// Check that timestamps are parsed
|
|
assert.False(t, session.StartedAt.IsZero(), "expected non-zero StartedAt")
|
|
assert.False(t, session.EndedAt.IsZero(), "expected non-zero EndedAt")
|
|
|
|
// Check that file info is populated
|
|
assert.NotEmpty(t, session.File.Path, "expected non-empty file path")
|
|
assert.NotZero(t, session.File.Size, "expected non-zero file size")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractIflowProjectHints(t *testing.T) {
|
|
cwd, gitBranch := ExtractIflowProjectHints("testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl")
|
|
|
|
// Expected values from the test file
|
|
assert.Equal(t, "C:\\exp\\docker-image-retagger", cwd, "cwd")
|
|
|
|
// gitBranch is null in this test file
|
|
assert.Empty(t, gitBranch, "gitBranch")
|
|
}
|
|
|
|
func TestIflowSystemMessageFiltering(t *testing.T) {
|
|
results, err := parseIflowSessionForTest(
|
|
t,
|
|
"testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
|
|
"test-project",
|
|
"local",
|
|
)
|
|
|
|
require.NoError(t, err, "ParseIflowSession error")
|
|
require.NotEmpty(t, results, "expected at least one result")
|
|
|
|
messages := results[0].Messages
|
|
|
|
// Verify that user messages have content
|
|
for _, msg := range messages {
|
|
if msg.Role == RoleUser {
|
|
hasContent := msg.Content != "" || len(msg.ToolResults) > 0
|
|
assert.True(t, hasContent,
|
|
"user message at ordinal %d should have content or tool results", msg.Ordinal)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIflowToolCallParsing(t *testing.T) {
|
|
results, err := parseIflowSessionForTest(
|
|
t,
|
|
"testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
|
|
"test-project",
|
|
"local",
|
|
)
|
|
|
|
require.NoError(t, err, "ParseIflowSession error")
|
|
require.NotEmpty(t, results, "expected at least one result")
|
|
|
|
messages := results[0].Messages
|
|
|
|
// After deduplication of streaming updates, the fixture
|
|
// should retain all meaningful messages (user prompts,
|
|
// final assistant turns, and tool results).
|
|
hasToolUse := false
|
|
hasToolResult := false
|
|
for _, msg := range messages {
|
|
assert.Contains(t, []RoleType{RoleUser, RoleAssistant}, msg.Role, "unexpected role")
|
|
assert.GreaterOrEqual(t, msg.Ordinal, 0, "invalid ordinal")
|
|
if len(msg.ToolCalls) > 0 {
|
|
hasToolUse = true
|
|
}
|
|
if len(msg.ToolResults) > 0 {
|
|
hasToolResult = true
|
|
}
|
|
}
|
|
assert.True(t, hasToolUse, "expected at least one message with tool calls")
|
|
assert.True(t, hasToolResult, "expected at least one message with tool results")
|
|
}
|
|
|
|
func TestIflowBurstMerge(t *testing.T) {
|
|
results, err := parseIflowSessionForTest(
|
|
t,
|
|
"testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
|
|
"test-project",
|
|
"local",
|
|
)
|
|
|
|
require.NoError(t, err, "ParseIflowSession error")
|
|
require.NotEmpty(t, results, "expected at least one result")
|
|
|
|
messages := results[0].Messages
|
|
|
|
// The first assistant message (ordinal 1) is a merged
|
|
// streaming burst from lines 1-4 of the fixture. It must
|
|
// retain the explanatory text from the first snapshot and
|
|
// all three unique read_file tool calls.
|
|
require.GreaterOrEqual(t, len(messages), 2, "expected at least 2 messages")
|
|
|
|
first := messages[1]
|
|
require.Equal(t, RoleAssistant, first.Role, "expected assistant at ordinal 1")
|
|
assert.Contains(t, first.Content, "DOCKER_API_VERSION", "first assistant burst lost explanatory text")
|
|
assert.Len(t, first.ToolCalls, 3, "expected 3 tool calls in first burst")
|
|
|
|
// Verify every tool_result in the session has a matching
|
|
// tool_call somewhere, confirming no orphaned results.
|
|
callIDs := map[string]bool{}
|
|
resultIDs := map[string]bool{}
|
|
for _, m := range messages {
|
|
for _, tc := range m.ToolCalls {
|
|
callIDs[tc.ToolUseID] = true
|
|
}
|
|
for _, tr := range m.ToolResults {
|
|
resultIDs[tr.ToolUseID] = true
|
|
}
|
|
}
|
|
for id := range resultIDs {
|
|
assert.Truef(t, callIDs[id], "orphaned tool_result %s has no tool_call", id)
|
|
}
|
|
}
|
|
|
|
func TestIflowBurstBoundary(t *testing.T) {
|
|
// Two assistant snapshots with the same parentUuid and
|
|
// sub-second timestamps, but separated by a user entry.
|
|
// They must NOT be merged into one burst.
|
|
base := time.Date(2026, 1, 21, 5, 56, 52, 0, time.UTC)
|
|
mkLine := func(typ, uuid, parent, text string) string {
|
|
return `{"type":"` + typ +
|
|
`","uuid":"` + uuid +
|
|
`","parentUuid":"` + parent +
|
|
`","message":{"content":"` + text + `"}}`
|
|
}
|
|
|
|
entries := []dagEntryIflow{
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a1",
|
|
parentUuid: "root",
|
|
lineIndex: 0,
|
|
timestamp: base,
|
|
line: mkLine(
|
|
"assistant", "a1", "root", "first snapshot",
|
|
),
|
|
},
|
|
{
|
|
entryType: "user",
|
|
uuid: "u1",
|
|
parentUuid: "a1",
|
|
lineIndex: 1,
|
|
timestamp: base.Add(100 * time.Millisecond),
|
|
line: mkLine(
|
|
"user", "u1", "a1", "tool result",
|
|
),
|
|
},
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a2",
|
|
parentUuid: "root",
|
|
lineIndex: 2,
|
|
timestamp: base.Add(200 * time.Millisecond),
|
|
line: mkLine(
|
|
"assistant", "a2", "root", "second snapshot",
|
|
),
|
|
},
|
|
}
|
|
|
|
result := deduplicateIflowEntries(entries)
|
|
|
|
// All three entries must survive: the user entry between
|
|
// the two assistant entries prevents burst merging.
|
|
require.Len(t, result, 3, "expected 3 entries")
|
|
assert.Equal(t, "a1", result[0].uuid)
|
|
assert.Equal(t, "u1", result[1].uuid)
|
|
assert.Equal(t, "a2", result[2].uuid)
|
|
|
|
// Also test: different-parent assistant between snapshots.
|
|
entries2 := []dagEntryIflow{
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a1",
|
|
parentUuid: "root",
|
|
lineIndex: 0,
|
|
timestamp: base,
|
|
line: mkLine(
|
|
"assistant", "a1", "root", "first",
|
|
),
|
|
},
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a3",
|
|
parentUuid: "other",
|
|
lineIndex: 1,
|
|
timestamp: base.Add(50 * time.Millisecond),
|
|
line: mkLine(
|
|
"assistant", "a3", "other", "unrelated",
|
|
),
|
|
},
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a2",
|
|
parentUuid: "root",
|
|
lineIndex: 2,
|
|
timestamp: base.Add(100 * time.Millisecond),
|
|
line: mkLine(
|
|
"assistant", "a2", "root", "second",
|
|
),
|
|
},
|
|
}
|
|
|
|
result2 := deduplicateIflowEntries(entries2)
|
|
require.Len(t, result2, 3, "expected 3 entries with interleaved parent")
|
|
|
|
// Third case: a non-user/assistant event (e.g. system) was
|
|
// filtered out before deduplication runs, so entries are
|
|
// adjacent in the slice but have a gap in lineIndex.
|
|
entries3 := []dagEntryIflow{
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a1",
|
|
parentUuid: "root",
|
|
lineIndex: 1,
|
|
timestamp: base,
|
|
line: mkLine(
|
|
"assistant", "a1", "root", "first",
|
|
),
|
|
},
|
|
// lineIndex 2 was a system event, now filtered out
|
|
{
|
|
entryType: "assistant",
|
|
uuid: "a2",
|
|
parentUuid: "root",
|
|
lineIndex: 3,
|
|
timestamp: base.Add(100 * time.Millisecond),
|
|
line: mkLine(
|
|
"assistant", "a2", "root", "second",
|
|
),
|
|
},
|
|
}
|
|
|
|
result3 := deduplicateIflowEntries(entries3)
|
|
require.Len(t, result3, 2, "expected 2 entries with filtered-event gap")
|
|
}
|
|
|
|
func TestIflowTimestampParsing(t *testing.T) {
|
|
results, err := parseIflowSessionForTest(
|
|
t,
|
|
"testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
|
|
"test-project",
|
|
"local",
|
|
)
|
|
|
|
require.NoError(t, err, "ParseIflowSession error")
|
|
require.NotEmpty(t, results, "expected at least one result")
|
|
|
|
session := results[0].Session
|
|
|
|
// Verify timestamps are in reasonable range
|
|
assert.True(t, session.StartedAt.Before(time.Now()), "expected StartedAt to be in the past")
|
|
assert.True(t, session.EndedAt.Before(time.Now()), "expected EndedAt to be in the past")
|
|
assert.False(t, session.StartedAt.After(session.EndedAt), "expected StartedAt to be before EndedAt")
|
|
|
|
// Verify message timestamps
|
|
for _, msg := range results[0].Messages {
|
|
if !msg.Timestamp.IsZero() {
|
|
assert.Falsef(t, msg.Timestamp.Before(session.StartedAt),
|
|
"message timestamp before session start: %v < %v", msg.Timestamp, session.StartedAt)
|
|
assert.Falsef(t, msg.Timestamp.After(session.EndedAt),
|
|
"message timestamp after session end: %v > %v", msg.Timestamp, session.EndedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIflowSessionIDExtraction(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expectID string
|
|
}{
|
|
{
|
|
filename: "session-96e6d875-92eb-40b9-b193-a9ba99f0f709.jsonl",
|
|
expectID: "96e6d875-92eb-40b9-b193-a9ba99f0f709",
|
|
},
|
|
{
|
|
filename: "session-abc123-def456.jsonl",
|
|
expectID: "abc123-def456",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.filename, func(t *testing.T) {
|
|
sessionID := filepath.Base(tt.filename)
|
|
sessionID = strings.TrimSuffix(sessionID, ".jsonl")
|
|
if trimmed, ok := strings.CutPrefix(sessionID, "session-"); ok {
|
|
sessionID = trimmed
|
|
}
|
|
|
|
assert.Equal(t, tt.expectID, sessionID, "ID")
|
|
})
|
|
}
|
|
}
|