From 26dec1c15068eb39c41183dedb0d5cbcd25c4de2 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sun, 21 Jun 2026 14:02:54 -0400 Subject: [PATCH] feat(parser): migrate kimi provider Kimi uses two wire.jsonl layouts whose raw IDs include colon-delimited path components, so it cannot rely entirely on the generic JSONL raw-ID lookup. Moving it behind a concrete provider keeps discovery and source classification on the shared JSONL helper while preserving Kimi-specific layout validation and lookup semantics.\n\nThe provider keeps legacy support for both the .kimi project/session layout and the .kimi-code workdir/session/agents layout, including symlinked directories, invalid component filtering, project hints, deleted-path classification, and parser output normalization. test(parser): cover kimi new-layout provider parse The roborev design review questioned whether the provider-backed Kimi migration proved the newer .kimi-code layout could round-trip through lookup and parsing. The existing parser and lookup code already handled that raw ID shape, but the provider tests only parsed the legacy layout.\n\nThis adds provider-level coverage for the .kimi-code workdir/session/agents layout so the branch itself documents the persisted session ID, project hint, source path, machine, hash propagation, and message output expected from that source shape. test(parser): opt kimi into provider shadow Kimi now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining legacy-only and additive. Lower provider opt-ins stay inherited and later branches own their provider modes. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare kimi shadow parity Kimi is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseKimiSession. The test covers both the legacy project/session wire.jsonl layout and the newer .kimi-code agents layout, keeping the fragile path-derived ID and project behavior visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesKimiLegacyParser|TestKimiProvider|TestParseKimi|TestSyncPathsAndSingleSession_KimiNewLayout|TestClassifyOnePath_Kimi' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; make nilaway refactor(parser): fold kimi into provider Move Kimi parse and raw-ID source lookup onto the concrete provider and remove package-level discover/find/parse entrypoints. Route Kimi sync classification and processing through provider changed-path handling so the branch migrates the provider instead of preserving legacy dispatch. --- internal/parser/kimi.go | 144 +---------------- internal/parser/kimi_provider.go | 134 ++++++++++++++++ internal/parser/kimi_provider_test.go | 214 ++++++++++++++++++++++++++ internal/parser/kimi_test.go | 166 +++++++++++++------- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/types.go | 6 +- internal/sync/classify_kimi_test.go | 33 ++-- internal/sync/engine.go | 85 ---------- 9 files changed, 485 insertions(+), 301 deletions(-) create mode 100644 internal/parser/kimi_provider.go create mode 100644 internal/parser/kimi_provider_test.go diff --git a/internal/parser/kimi.go b/internal/parser/kimi.go index d5e98c6c3..5e616e607 100644 --- a/internal/parser/kimi.go +++ b/internal/parser/kimi.go @@ -5,150 +5,12 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/tidwall/gjson" ) -// DiscoverKimiSessions finds all wire.jsonl files under the Kimi -// sessions directory. It supports two layouts: -// -// Legacy (".kimi/sessions"): -// -// ///wire.jsonl -// -// New (".kimi-code/sessions"): -// -// /_/session_/agents//wire.jsonl -func DiscoverKimiSessions(sessionsDir string) []DiscoveredFile { - if sessionsDir == "" { - return nil - } - - projDirs, err := os.ReadDir(sessionsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, projEntry := range projDirs { - if !isDirOrSymlink(projEntry, sessionsDir) { - continue - } - - projDir := filepath.Join(sessionsDir, projEntry.Name()) - sessionDirs, err := os.ReadDir(projDir) - if err != nil { - continue - } - - for _, sessEntry := range sessionDirs { - if !isDirOrSymlink(sessEntry, projDir) { - continue - } - - sessDir := filepath.Join(projDir, sessEntry.Name()) - - // Legacy layout. - wirePath := filepath.Join(sessDir, "wire.jsonl") - if _, err := os.Stat(wirePath); err == nil { - // The project and session names become ':'-delimited - // session-ID components; skip sessions whose names - // cannot round-trip through FindKimiSourceFile. - if kimiIDComponentsValid( - projEntry.Name(), sessEntry.Name(), - ) { - files = append(files, DiscoveredFile{ - Path: wirePath, - Project: DecodeKimiProjectDir(projEntry.Name()), - Agent: AgentKimi, - }) - } - continue - } - - // New .kimi-code layout. - agentsDir := filepath.Join(sessDir, "agents") - agentEntries, err := os.ReadDir(agentsDir) - if err != nil { - continue - } - for _, agentEntry := range agentEntries { - if !isDirOrSymlink(agentEntry, agentsDir) { - continue - } - wirePath = filepath.Join( - agentsDir, agentEntry.Name(), "wire.jsonl", - ) - if _, err := os.Stat(wirePath); err == nil && - kimiIDComponentsValid( - projEntry.Name(), - sessEntry.Name(), - agentEntry.Name(), - ) { - files = append(files, DiscoveredFile{ - Path: wirePath, - Project: DecodeKimiProjectDir(projEntry.Name()), - Agent: AgentKimi, - }) - } - } - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindKimiSourceFile locates a Kimi session file by its raw -// session ID (without the "kimi:" prefix). Supported raw ID formats: -// -// Legacy: -// -// : -// → ///wire.jsonl -// -// New (.kimi-code): -// -// _:: -// → /_//agents//wire.jsonl -func FindKimiSourceFile(sessionsDir, rawID string) string { - if sessionsDir == "" { - return "" - } - - parts := strings.Split(rawID, ":") - for _, p := range parts { - if !IsValidSessionID(p) { - return "" - } - } - - switch len(parts) { - case 2: - // Legacy layout. - candidate := filepath.Join( - sessionsDir, parts[0], parts[1], "wire.jsonl", - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - case 3: - // New .kimi-code layout. - candidate := filepath.Join( - sessionsDir, parts[0], parts[2], "agents", parts[1], "wire.jsonl", - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "" -} - // kimiSessionIDFromPath extracts the raw Kimi session ID from its // wire.jsonl path. Legacy paths yield ":"; .kimi-code // paths yield "::". @@ -208,7 +70,7 @@ func isKimiHash(s string) bool { } // kimiIDComponentsValid reports whether the given path-derived -// components can form a session ID that FindKimiSourceFile can +// components can form a session ID that provider raw-ID lookup can // round-trip back to the source file. Each component must itself be a // valid session ID (alphanumeric, '-', '_'); a ':' or any other // character outside that set would break the ':'-delimited ID split @@ -223,11 +85,11 @@ func kimiIDComponentsValid(components ...string) bool { return true } -// ParseKimiSession parses a Kimi wire.jsonl file. Legacy Kimi CLI +// parseSession parses a Kimi wire.jsonl file. Legacy Kimi CLI // sessions store nested message.type records (TurnBegin, ContentPart, // ToolCall, ToolResult, StatusUpdate, TurnEnd). Kimi Code sessions store // top-level records (turn.prompt, context.append_loop_event, usage.record). -func ParseKimiSession( +func parseKimiSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/kimi_provider.go b/internal/parser/kimi_provider.go new file mode 100644 index 000000000..edfb4a5fd --- /dev/null +++ b/internal/parser/kimi_provider.go @@ -0,0 +1,134 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// Kimi stores each session as a wire.jsonl transcript under a per-workspace +// directory, with subagent transcripts nested under an "agents" subdirectory. +// It is a directory-of-files provider: discovery, watching, change +// classification, and fingerprinting come from JSONLSourceSet. The ParseFile +// option makes that source set a full SourceSet so it rides the generic +// factory; RawSessionIDSourceFiles reconstructs the wire.jsonl path from a +// colon-joined raw ID, which the standard filename-stem lookup cannot match. +func newKimiProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + kimiProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newKimiSourceSet(cfg.Roots) }, + ) +} + +func newKimiSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentKimi, roots, + withRecursive(), + withSymlinkFollowing(), + withIncludePath(isKimiSourcePath), + withProjectHint(kimiProjectHintFromPath), + withSessionIDFromPath(func(root, path string) string { + if !isKimiSourcePath(root, path) { + return "" + } + return kimiSessionIDFromPath(path) + }), + withRawSessionIDSourceFiles(kimiRawSessionIDSourceFiles), + withParseFile(kimiParseFile), + ) +} + +func kimiParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseKimiSession(path, req.Source.ProjectHint, req.Machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil +} + +// kimiRawSessionIDSourceFiles reconstructs wire.jsonl candidate paths from a +// colon-joined raw ID. A two-part ID maps to /// +// wire.jsonl; a three-part ID adds the agents/ subagent layout +// ///agents//wire.jsonl. +func kimiRawSessionIDSourceFiles(roots []string, rawID string) []string { + parts := strings.Split(rawID, ":") + if !kimiIDComponentsValid(parts...) { + return nil + } + var candidates []string + for _, root := range roots { + if root == "" { + continue + } + switch len(parts) { + case 2: + candidates = append( + candidates, + filepath.Join(root, parts[0], parts[1], "wire.jsonl"), + ) + case 3: + candidates = append(candidates, filepath.Join( + root, parts[0], parts[2], "agents", parts[1], "wire.jsonl", + )) + } + } + return candidates +} + +func isKimiSourcePath(root, path string) bool { + parts, ok := kimiSourceRelParts(root, path) + if !ok || len(parts) == 0 || parts[len(parts)-1] != "wire.jsonl" { + return false + } + switch len(parts) { + case 3: + return kimiIDComponentsValid(parts[0], parts[1]) + case 5: + return parts[2] == "agents" && + kimiIDComponentsValid(parts[0], parts[1], parts[3]) + default: + return false + } +} + +func kimiProjectHintFromPath(root, path string) string { + parts, ok := kimiSourceRelParts(root, path) + if !ok || len(parts) == 0 { + return "" + } + return DecodeKimiProjectDir(parts[0]) +} + +func kimiSourceRelParts(root, path string) ([]string, bool) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return nil, false + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return nil, false + } + } + return parts, true +} + +func kimiProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/kimi_provider_test.go b/internal/parser/kimi_provider_test.go new file mode 100644 index 000000000..88b811fa2 --- /dev/null +++ b/internal/parser/kimi_provider_test.go @@ -0,0 +1,214 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKimiProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentKimi) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestKimiProviderSourceMethods(t *testing.T) { + root := t.TempDir() + legacyPath := filepath.Join(root, "abc123", "uuid-1", "wire.jsonl") + newPath := filepath.Join( + root, + "wd_kimi-code_057f5c09ee3f", + "session_uuid-2", + "agents", + "main", + "wire.jsonl", + ) + invalidPath := filepath.Join( + root, + "wd_kimi-code_057f5c09ee3f", + "session_uuid-3", + "agents", + "sub agent", + "wire.jsonl", + ) + writeSourceFile(t, legacyPath, kimiProviderFixture("legacy question")) + writeSourceFile(t, newPath, kimiProviderFixture("new layout question")) + writeSourceFile(t, invalidPath, kimiProviderFixture("bad agent")) + writeSourceFile(t, filepath.Join(root, "abc123", "uuid-1", "other.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "wire.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"*.jsonl"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, AgentKimi, discovered[0].Provider) + assert.Equal(t, legacyPath, discovered[0].DisplayPath) + assert.Equal(t, "abc123", discovered[0].ProjectHint) + assert.Equal(t, newPath, discovered[1].DisplayPath) + assert.Equal(t, "kimi-code", discovered[1].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kimi:abc123:uuid-1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, legacyPath, found.DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, legacyPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "wd_kimi-code_057f5c09ee3f:main:session_uuid-2", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, newPath, found.DisplayPath) + + require.NoError(t, os.Remove(legacyPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: legacyPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, legacyPath, changed[0].DisplayPath) +} + +func TestKimiProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + targetRoot := t.TempDir() + targetProject := filepath.Join(targetRoot, "abc123") + sourceProject := filepath.Join(root, "abc123") + sourcePath := filepath.Join(sourceProject, "uuid-1", "wire.jsonl") + writeSourceFile( + t, + filepath.Join(targetProject, "uuid-1", "wire.jsonl"), + kimiProviderFixture("from symlink"), + ) + if err := os.Symlink(targetProject, sourceProject); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kimi:abc123:uuid-1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestKimiProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "abc123", "uuid-1", "wire.jsonl") + writeSourceFile(t, sourcePath, kimiProviderFixture("provider question")) + + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "kimi:abc123:uuid-1", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.Project) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func TestKimiProviderParseNewLayoutRoundTrip(t *testing.T) { + root := t.TempDir() + rawID := "wd_kimi-code_057f5c09ee3f:main:session_uuid-2" + sourcePath := filepath.Join( + root, + "wd_kimi-code_057f5c09ee3f", + "session_uuid-2", + "agents", + "main", + "wire.jsonl", + ) + writeSourceFile(t, sourcePath, kimiProviderFixture("new layout provider question")) + + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~kimi:" + rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, source.DisplayPath) + assert.Equal(t, "kimi-code", source.ProjectHint) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + session := outcome.Results[0].Result.Session + assert.Equal(t, "kimi:"+rawID, session.ID) + assert.Equal(t, "kimi-code", session.Project) + assert.Equal(t, "devbox", session.Machine) + assert.Equal(t, sourcePath, session.File.Path) + assert.Equal(t, "abc123", session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func kimiProviderFixture(firstMessage string) string { + return `{"type":"metadata","protocol_version":"1.3"}` + "\n" + + `{"timestamp":1704067200.0,"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"` + firstMessage + `"}]}}}` + "\n" + + `{"timestamp":1704067201.0,"message":{"type":"ContentPart","payload":{"type":"text","text":"Done."}}}` + "\n" + + `{"timestamp":1704067202.0,"message":{"type":"TurnEnd","payload":{}}}` + "\n" +} diff --git a/internal/parser/kimi_test.go b/internal/parser/kimi_test.go index 00b85796a..39e9a2c36 100644 --- a/internal/parser/kimi_test.go +++ b/internal/parser/kimi_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -43,6 +44,14 @@ func writeKimiCodeWireJSONL( return path } +func parseKimiSessionForTest( + t *testing.T, + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return parseKimiSession(path, project, machine) +} + func TestParseKimiSession_Basic(t *testing.T) { path := writeKimiWireJSONL(t, "abc123", "sess-uuid-1234", @@ -54,7 +63,7 @@ func TestParseKimiSession_Basic(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession( + sess, msgs, err := parseKimiSessionForTest(t, path, "myproject", "local", ) require.NoError(t, err) @@ -94,7 +103,7 @@ func TestParseKimiSession_ThinkingAndToolUse(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession( + sess, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -139,7 +148,7 @@ func TestParseKimiSession_Empty(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession( + sess, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -159,7 +168,7 @@ func TestParseKimiSession_ErrorToolResult(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession( + sess, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -184,7 +193,7 @@ func TestParseKimiSession_ArrayToolResult(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession( + sess, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -216,7 +225,7 @@ func TestParseKimiSession_MultipleStatusUpdates(t *testing.T) { }, ) - sess, _, err := ParseKimiSession( + sess, _, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -239,7 +248,7 @@ func TestParseKimiSession_StatusUpdate(t *testing.T) { }, ) - sess, _, err := ParseKimiSession( + sess, _, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -262,7 +271,7 @@ func TestParseKimiSession_ZeroValuedStatusUpdatePreservesCoverage(t *testing.T) }, ) - sess, _, err := ParseKimiSession( + sess, _, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -284,7 +293,7 @@ func TestParseKimiSession_NoProject(t *testing.T) { }, ) - sess, _, err := ParseKimiSession(path, "", "local") + sess, _, err := parseKimiSessionForTest(t, path, "", "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "kimi", sess.Project) @@ -304,7 +313,7 @@ func TestParseKimiSession_MessageTimestamps(t *testing.T) { }, ) - _, msgs, err := ParseKimiSession( + _, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -345,7 +354,7 @@ func TestParseKimiSession_EmptyFragmentTimestamp(t *testing.T) { }, ) - _, msgs, err := ParseKimiSession( + _, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -369,7 +378,7 @@ func TestParseKimiSession_EmptyFragmentTimestamp(t *testing.T) { }, ) - _, msgs, err := ParseKimiSession( + _, msgs, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -380,7 +389,7 @@ func TestParseKimiSession_EmptyFragmentTimestamp(t *testing.T) { } func TestParseKimiSession_MissingFile(t *testing.T) { - _, _, err := ParseKimiSession( + _, _, err := parseKimiSessionForTest(t, "/nonexistent/wire.jsonl", "proj", "local", ) assert.Error(t, err) @@ -398,7 +407,7 @@ func TestParseKimiSession_FirstMessageTruncation(t *testing.T) { }, ) - sess, _, err := ParseKimiSession( + sess, _, err := parseKimiSessionForTest(t, path, "testproj", "local", ) require.NoError(t, err) @@ -424,18 +433,26 @@ func TestDiscoverKimiSessions(t *testing.T) { []byte(`{"type":"metadata"}`+"\n"), 0o644, )) - files := DiscoverKimiSessions(dir) - require.Equal(t, 2, len(files)) - assert.Equal(t, AgentKimi, files[0].Agent) - assert.Equal(t, "abc123", files[0].Project) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + assert.Equal(t, []string{ + filepath.Join(sessDir, "wire.jsonl"), + filepath.Join(sessDir2, "wire.jsonl"), + }, sourceDisplayPaths(sources)) + assert.Equal(t, []string{"abc123", "abc123"}, sourceProjects(sources)) } func TestDiscoverKimiSessions_Empty(t *testing.T) { - files := DiscoverKimiSessions("") - assert.Nil(t, files) - - files = DiscoverKimiSessions("/nonexistent") - assert.Nil(t, files) + provider, ok := NewProvider(AgentKimi, ProviderConfig{ + Roots: []string{"", "/nonexistent"}, + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, sources) } func TestFindKimiSourceFile(t *testing.T) { @@ -449,15 +466,29 @@ func TestFindKimiSourceFile(t *testing.T) { wirePath, []byte("{}"), 0o644, )) - found := FindKimiSourceFile(dir, "abc123:uuid-1") - assert.Equal(t, wirePath, found) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "abc123:uuid-1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, wirePath, found.DisplayPath) - assert.Equal(t, "", - FindKimiSourceFile(dir, "abc123:nonexistent")) - assert.Equal(t, "", - FindKimiSourceFile(dir, "invalid")) - assert.Equal(t, "", - FindKimiSourceFile("", "abc123:uuid-1")) + for _, rawID := range []string{"abc123:nonexistent", "invalid"} { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + assert.False(t, ok) + } + emptyProvider, ok := NewProvider(AgentKimi, ProviderConfig{}) + require.True(t, ok) + _, ok, err = emptyProvider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "abc123:uuid-1", + }) + require.NoError(t, err) + assert.False(t, ok) } func TestDiscoverKimiSessions_NewLayout(t *testing.T) { @@ -472,12 +503,14 @@ func TestDiscoverKimiSessions_NewLayout(t *testing.T) { wirePath, []byte(`{"type":"metadata"}`+"\n"), 0o644, )) - files := DiscoverKimiSessions(dir) - require.Equal(t, 1, len(files)) - assert.Equal(t, AgentKimi, files[0].Agent) - assert.Equal(t, wirePath, files[0].Path) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, wirePath, sources[0].DisplayPath) // Project is decoded from "wd__". - assert.Equal(t, "claude-code", files[0].Project) + assert.Equal(t, "claude-code", sources[0].ProjectHint) } func TestDiscoverKimiSessions_NewLayout_NonMainAgent(t *testing.T) { @@ -492,9 +525,12 @@ func TestDiscoverKimiSessions_NewLayout_NonMainAgent(t *testing.T) { wirePath, []byte(`{"type":"metadata"}`+"\n"), 0o644, )) - files := DiscoverKimiSessions(dir) - require.Equal(t, 1, len(files)) - assert.Equal(t, wirePath, files[0].Path) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, wirePath, sources[0].DisplayPath) } func TestFindKimiSourceFile_NewLayout(t *testing.T) { @@ -509,14 +545,26 @@ func TestFindKimiSourceFile_NewLayout(t *testing.T) { wirePath, []byte("{}"), 0o644, )) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) rawID := workdirDir + ":main:" + sessionDir - found := FindKimiSourceFile(dir, rawID) - assert.Equal(t, wirePath, found) - - assert.Equal(t, "", - FindKimiSourceFile(dir, workdirDir+":main:nonexistent")) - assert.Equal(t, "", - FindKimiSourceFile(dir, workdirDir+":"+sessionDir)) + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, wirePath, found.DisplayPath) + + for _, rawID := range []string{ + workdirDir + ":main:nonexistent", + workdirDir + ":" + sessionDir, + } { + _, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + assert.False(t, ok) + } } func TestParseKimiSession_NewLayoutSessionID(t *testing.T) { @@ -530,7 +578,7 @@ func TestParseKimiSession_NewLayoutSessionID(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession(path, "myproject", "local") + sess, msgs, err := parseKimiSessionForTest(t, path, "myproject", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -561,7 +609,7 @@ func TestParseKimiSession_NativeKimiCodeEvents(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession(path, "myproject", "local") + sess, msgs, err := parseKimiSessionForTest(t, path, "myproject", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -619,7 +667,7 @@ func TestParseKimiSession_NativeKimiCodeToolCall(t *testing.T) { }, ) - sess, msgs, err := ParseKimiSession(path, "myproject", "local") + sess, msgs, err := parseKimiSessionForTest(t, path, "myproject", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -661,7 +709,7 @@ func TestParseKimiSession_NewLayout_AgentZero(t *testing.T) { }, ) - sess, _, err := ParseKimiSession(path, "myproject", "local") + sess, _, err := parseKimiSessionForTest(t, path, "myproject", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -692,9 +740,12 @@ func TestDiscoverKimiSessions_MixedLayouts(t *testing.T) { newPath, []byte(`{"type":"metadata"}`+"\n"), 0o644, )) - files := DiscoverKimiSessions(dir) - require.Equal(t, 2, len(files)) - paths := []string{files[0].Path, files[1].Path} + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + paths := sourceDisplayPaths(sources) assert.Contains(t, paths, legacyPath) assert.Contains(t, paths, newPath) } @@ -769,7 +820,10 @@ func TestDiscoverKimiSessions_NewLayout_RejectsInvalidComponent(t *testing.T) { goodPath, []byte(`{"type":"metadata"}`+"\n"), 0o644, )) - files := DiscoverKimiSessions(dir) - require.Len(t, files, 1) - assert.Equal(t, goodPath, files[0].Path) + provider, ok := NewProvider(AgentKimi, ProviderConfig{Roots: []string{dir}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, goodPath, sources[0].DisplayPath) } diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 4b2d7e76a..50038e618 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -359,6 +359,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newIflowProviderFactory(def) case AgentGptme: return newGptmeProviderFactory(def) + case AgentKimi: + return newKimiProviderFactory(def) case AgentOMP, AgentPi: return newPiProviderFactory(def) case AgentQwen: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 4a1164d78..2b9fa28e3 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -38,7 +38,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentDeepSeekTUI: ProviderMigrationProviderAuthoritative, AgentOpenClaw: ProviderMigrationLegacyOnly, AgentQClaw: ProviderMigrationLegacyOnly, - AgentKimi: ProviderMigrationLegacyOnly, + AgentKimi: ProviderMigrationProviderAuthoritative, AgentClaudeAI: ProviderMigrationLegacyOnly, AgentChatGPT: ProviderMigrationLegacyOnly, AgentKiro: ProviderMigrationLegacyOnly, diff --git a/internal/parser/types.go b/internal/parser/types.go index 888bfba7e..6a763539c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -389,10 +389,8 @@ var Registry = []AgentDef{ ".kimi/sessions", ".kimi-code/sessions", }, - IDPrefix: "kimi:", - FileBased: true, - DiscoverFunc: DiscoverKimiSessions, - FindSourceFunc: FindKimiSourceFile, + IDPrefix: "kimi:", + FileBased: true, }, { Type: AgentClaudeAI, diff --git a/internal/sync/classify_kimi_test.go b/internal/sync/classify_kimi_test.go index 4d01ad180..cfbd6533b 100644 --- a/internal/sync/classify_kimi_test.go +++ b/internal/sync/classify_kimi_test.go @@ -10,13 +10,11 @@ import ( "go.kenn.io/agentsview/internal/parser" ) -// TestClassifyOnePath_Kimi covers the file-watcher classification -// gate for both Kimi session layouts. The new .kimi-code layout has -// a 5-segment relative path and must be classified (and its project -// decoded) just like the legacy 3-segment layout. classifyOnePath is -// the sole consumer of SyncPaths / watcher events, so this is what -// guards live updates for new-layout files. -func TestClassifyOnePath_Kimi(t *testing.T) { +// TestEngineClassifyKimiPaths covers provider changed-path classification for +// both Kimi session layouts. The new .kimi-code layout has a 5-segment relative +// path and must be classified with its decoded project just like the legacy +// 3-segment layout. +func TestEngineClassifyKimiPaths(t *testing.T) { dir := t.TempDir() // Legacy: ///wire.jsonl @@ -51,11 +49,15 @@ func TestClassifyOnePath_Kimi(t *testing.T) { } eng := &Engine{ + db: openTestDB(t), agentDirs: map[parser.AgentType][]string{ parser.AgentKimi: {dir}, }, + providerFactories: providerFactoryMap(parser.ProviderFactories()), + providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentKimi: parser.ProviderMigrationProviderAuthoritative, + }, } - geminiMap := make(map[string]map[string]string) tests := []struct { name string @@ -95,13 +97,16 @@ func TestClassifyOnePath_Kimi(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := eng.classifyOnePath(tt.path, geminiMap) - assert.Equal(t, tt.want, ok) - if ok { - assert.Equal(t, parser.AgentKimi, got.Agent) - assert.Equal(t, tt.project, got.Project) - assert.Equal(t, tt.path, got.Path) + files := eng.classifyPaths([]string{tt.path}) + if !tt.want { + assert.Empty(t, files) + return } + require.Len(t, files, 1) + got := files[0] + assert.Equal(t, parser.AgentKimi, got.Agent) + assert.Equal(t, tt.project, got.Project) + assert.Equal(t, tt.path, got.Path) }) } } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 6ed17725b..a154525f6 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1188,40 +1188,6 @@ func (e *Engine) classifyOnePath( } } - // Kimi: ///wire.jsonl (legacy) - // or ///agents//wire.jsonl (.kimi-code) - // Components that cannot round-trip through the ':'-delimited - // session ID (per IsValidSessionID) are left unclassified so they - // are never imported in a non-resyncable state. - for _, kimiDir := range e.agentDirs[parser.AgentKimi] { - if kimiDir == "" { - continue - } - if rel, ok := isUnder(kimiDir, path); ok { - parts := strings.Split(rel, sep) - switch { - case len(parts) == 3 && parts[2] == "wire.jsonl" && - parser.IsValidSessionID(parts[0]) && - parser.IsValidSessionID(parts[1]): - return parser.DiscoveredFile{ - Path: path, - Project: parser.DecodeKimiProjectDir(parts[0]), - Agent: parser.AgentKimi, - }, true - case len(parts) == 5 && parts[2] == "agents" && - parts[4] == "wire.jsonl" && - parser.IsValidSessionID(parts[0]) && - parser.IsValidSessionID(parts[1]) && - parser.IsValidSessionID(parts[3]): - return parser.DiscoveredFile{ - Path: path, - Project: parser.DecodeKimiProjectDir(parts[0]), - Agent: parser.AgentKimi, - }, true - } - } - } - // QwenPaw: //sessions/.json // or //sessions//.json for _, qwenpawDir := range e.agentDirs[parser.AgentQwenPaw] { @@ -4466,8 +4432,6 @@ func (e *Engine) processFile( res = e.processOpenClaw(file, info) case parser.AgentQClaw: res = e.processQClaw(file, info) - case parser.AgentKimi: - res = e.processKimi(file, info) case parser.AgentKiro: res = e.processKiro(file, info) case parser.AgentKiroIDE: @@ -6165,35 +6129,6 @@ func (e *Engine) processVisualStudioCopilot( } } -func (e *Engine) processKimi( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseKimiSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - func (e *Engine) processQwenPaw( file parser.DiscoveredFile, info os.FileInfo, ) processResult { @@ -9317,26 +9252,6 @@ func (e *Engine) SyncSingleSessionContext( } else { file.Project = filepath.Base(filepath.Dir(path)) } - case parser.AgentKimi: - // path is ///wire.jsonl (legacy) - // or ///agents//wire.jsonl (.kimi-code) - // In both layouts the project is the first path segment relative - // to the sessions dir. Deriving two levels up (the old approach) - // mis-resolves to "agents" under the .kimi-code layout. - for _, kimiDir := range e.agentDirs[parser.AgentKimi] { - rel, ok := isUnder(kimiDir, path) - if !ok { - continue - } - parts := strings.Split(rel, string(filepath.Separator)) - if len(parts) > 0 { - file.Project = parser.DecodeKimiProjectDir(parts[0]) - } - break - } - if file.Project == "" { - file.Project = "kimi" - } case parser.AgentQwenPaw: // path is //sessions/.json or // //sessions//.json