mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
* fix(kimi): gate --work-dir flag on probe, like --print (#1476) Same pattern as the #1456 / PR #1461 --print fix. Newer Kimi Code CLI builds no longer accept --work-dir, exiting with `error: unknown option --work-dir` whenever the user's config sets a non-default workspace directory. - agent/kimi/probe.go: kimiFlagSupport gains a WorkDir bool; the probe fills it from `kimi --help` (parseKimiHelpFlags already scans the --work-dir token, no parser changes needed). - agent/kimi/session.go buildArgs: --work-dir is now emitted only when flagSupport.WorkDir is true. The agent still runs in the correct directory via exec.Command.Dir (set separately), so omitting the flag on modern CLIs is functionally equivalent for users on default dirs and graceful for users on non-default dirs whose CLI just ignores it. - agent/kimi/probe_test.go: extend the legacy test to assert --work-dir is detected, add modernKimiHelpWithoutWorkDir constant that mirrors the build the reporter hits, plus full TestKimiFlagSupport_LegacyHelpSetsWorkDir / TestKimiFlagSupport_ModernWithoutWorkDir coverage of the probe mapping. - agent/kimi/session_test.go: TestBuildArgs_WorkDirFlagGated (no flag on modern CLI) + TestBuildArgs_WorkDirFlagEmitted (legacy CLI keeps it). Also asserts the work-dir value doesn't leak into args when the gate is closed, catching a partial-gate future bug. go test ./agent/kimi/... — all pass. * feat(kimi): native Kimi Code CLI dialect support Fixes #1561 The kimi agent targeted the legacy Python kimi-cli dialect; the newer Node.js Kimi Code CLI (kimi-code) speaks a different one. Extend the #1461/#1483 probe-gating approach to cover the remaining differences: - probe: also detect --quiet; add isModernFlavor() using --print absence as the family discriminator established in #1456 - buildArgs: resume with -r instead of --resume on the modern dialect (--resume is rejected; -r matches the CLI's own resume hint); gate --quiet and emulate quiet mode via local event suppression when the binary dropped it; never pass --yolo/--auto (bare --prompt already auto-approves, and Kimi Code rejects combining them) - stream-json: accept plain-string content for assistant/tool messages alongside the legacy block-array shape (format-tolerant, no gating) - session continuity: capture the session id from Kimi Code's stdout meta line {"role":"meta","type":"session.resume_hint"} instead of the legacy plain-text/stderr hint - session listing: scan both ~/.kimi/sessions and ~/.kimi-code/sessions, understand the Kimi Code state.json schema, honor its workDir field Tests: real v0.26.0 --help fixture, per-dialect arg tests, content-shape and meta-hint regression tests, dual-flavor session listing test, and an env-guarded live e2e (KIMI_LIVE_E2E=1) that verifies anchor recall across a resumed turn against the production binary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(kimi): don't report 0 messages for Kimi Code sessions in /list Kimi Code CLI sessions have no context.jsonl; their transcript lives at agents/main/wire.jsonl. Without a fallback, /list showed 0 messages for every modern session. Count user turns from wire.jsonl (and use the first user turn as the summary), matching legacy kimi-cli behavior. Addresses review feedback on #1564. * fix(kimi): adapt live e2e to main's Send(messageID, ...) signature CI lint failed on the PR merge ref: main added a messageID string param to AgentSession.Send (core/interfaces.go), but the PR still called the old 3-arg form in live_e2e_test.go, so the merged code failed to compile ('not enough arguments in call to s.Send'). origin/main was merged into kimi-code-flavor (no conflicts); kimi's Send now carries the 4-arg signature matching main. This updates the remaining 3-arg call site to the new signature. agent/kimi and core compile, vet and tests green. * fix(kimi): check f.Close error in transcript helpers (errcheck) golangci-lint flagged the deferred file Close calls in the new parseKimiTranscript/count* helpers as unchecked errors. Use an explicit blank-assignment close to satisfy errcheck. agent/kimi still compiles, vets and tests green. * feat(kimi): native Kimi Code CLI dialect support Fixes #1561 The kimi agent targeted the legacy Python kimi-cli dialect; the newer Node.js Kimi Code CLI (kimi-code) speaks a different one. Extend the #1461/#1483 probe-gating approach to cover the remaining differences: - probe: also detect --quiet; add isModernFlavor() using --print absence as the family discriminator established in #1456 - buildArgs: resume with -r instead of --resume on the modern dialect (--resume is rejected; -r matches the CLI's own resume hint); gate --quiet and emulate quiet mode via local event suppression when the binary dropped it; never pass --yolo/--auto (bare --prompt already auto-approves, and Kimi Code rejects combining them) - stream-json: accept plain-string content for assistant/tool messages alongside the legacy block-array shape (format-tolerant, no gating) - session continuity: capture the session id from Kimi Code's stdout meta line {"role":"meta","type":"session.resume_hint"} instead of the legacy plain-text/stderr hint - session listing: scan both ~/.kimi/sessions and ~/.kimi-code/sessions, understand the Kimi Code state.json schema, honor its workDir field Tests: real v0.26.0 --help fixture, per-dialect arg tests, content-shape and meta-hint regression tests, dual-flavor session listing test, and an env-guarded live e2e (KIMI_LIVE_E2E=1) that verifies anchor recall across a resumed turn against the production binary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(kimi): don't report 0 messages for Kimi Code sessions in /list Kimi Code CLI sessions have no context.jsonl; their transcript lives at agents/main/wire.jsonl. Without a fallback, /list showed 0 messages for every modern session. Count user turns from wire.jsonl (and use the first user turn as the summary), matching legacy kimi-cli behavior. Addresses review feedback on #1564. * fix(kimi): adapt live e2e to main's Send(messageID, ...) signature CI lint failed on the PR merge ref: main added a messageID string param to AgentSession.Send (core/interfaces.go), but the PR still called the old 3-arg form in live_e2e_test.go, so the merged code failed to compile ('not enough arguments in call to s.Send'). origin/main was merged into kimi-code-flavor (no conflicts); kimi's Send now carries the 4-arg signature matching main. This updates the remaining 3-arg call site to the new signature. agent/kimi and core compile, vet and tests green. * fix(kimi): check f.Close error in transcript helpers (errcheck) golangci-lint flagged the deferred file Close calls in the new parseKimiTranscript/count* helpers as unchecked errors. Use an explicit blank-assignment close to satisfy errcheck. agent/kimi still compiles, vets and tests green. * test(kimi): pin assistant string-content + tool_calls flush order (#1561) Ported from #1586: an assistant event carrying plain-string content AND tool_calls must surface the text as a thinking event (via flushPendingAsThinking) before the tool-use event, not drop it silently. * docs(changelog): add #1561 kimi-code dialect entry --------- Co-authored-by: dev-claudecode <dev-claudecode@cc-connect.local> Co-authored-by: whyihaveyou <whyihaveyou@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: QZP <qzp@QZPdeMacBook-Air.local> Co-authored-by: octopus <2841776039@qq.com>
279 lines
9.5 KiB
Go
279 lines
9.5 KiB
Go
package kimi
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func skipUnlessKimiAvailable(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("kimi"); err != nil {
|
|
t.Skipf("kimi CLI not in PATH, skipping: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMode(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"default", "default"},
|
|
{"DEFAULT", "default"},
|
|
{"yolo", "yolo"},
|
|
{"YOLO", "yolo"},
|
|
{"force", "yolo"},
|
|
{"bypass", "yolo"},
|
|
{"auto", "yolo"},
|
|
{"plan", "plan"},
|
|
{"quiet", "quiet"},
|
|
{"", "default"},
|
|
{"unknown", "default"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.expected, normalizeMode(c.input), "input: %s", c.input)
|
|
}
|
|
}
|
|
|
|
func TestAgentNew(t *testing.T) {
|
|
skipUnlessKimiAvailable(t)
|
|
agentInf, err := New(map[string]any{
|
|
"work_dir": "/tmp",
|
|
"model": "kimi-k2",
|
|
"mode": "yolo",
|
|
"timeout_mins": 15,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, agentInf)
|
|
|
|
a := agentInf.(*Agent)
|
|
assert.Equal(t, "kimi", a.Name())
|
|
assert.Equal(t, "/tmp", a.GetWorkDir())
|
|
assert.Equal(t, "yolo", a.GetMode())
|
|
assert.Equal(t, "kimi-k2", a.GetModel())
|
|
}
|
|
|
|
// TestAgentFields verifies Name/WorkDir/Mode/Model without requiring
|
|
// the kimi CLI on PATH — constructs the struct directly.
|
|
func TestAgentFields(t *testing.T) {
|
|
a := &Agent{
|
|
workDir: "/tmp",
|
|
model: "kimi-k2",
|
|
mode: "yolo",
|
|
cmd: "kimi",
|
|
activeIdx: -1,
|
|
}
|
|
assert.Equal(t, "kimi", a.Name())
|
|
assert.Equal(t, "Kimi", a.CLIDisplayName())
|
|
assert.Equal(t, "kimi", a.CLIBinaryName())
|
|
assert.Equal(t, "/tmp", a.GetWorkDir())
|
|
assert.Equal(t, "yolo", a.GetMode())
|
|
assert.Equal(t, "kimi-k2", a.GetModel())
|
|
}
|
|
|
|
func TestAgentSetters(t *testing.T) {
|
|
a := &Agent{workDir: "/tmp", mode: "default", activeIdx: -1}
|
|
|
|
a.SetWorkDir("/new/path")
|
|
assert.Equal(t, "/new/path", a.GetWorkDir())
|
|
|
|
a.SetModel("kimi-k2-5")
|
|
assert.Equal(t, "kimi-k2-5", a.GetModel())
|
|
|
|
a.SetMode("plan")
|
|
assert.Equal(t, "plan", a.GetMode())
|
|
}
|
|
|
|
func TestAgentPermissionModes(t *testing.T) {
|
|
a := &Agent{}
|
|
|
|
modes := a.PermissionModes()
|
|
require.Len(t, modes, 4)
|
|
assert.Equal(t, "default", modes[0].Key)
|
|
assert.Equal(t, "yolo", modes[1].Key)
|
|
assert.Equal(t, "plan", modes[2].Key)
|
|
assert.Equal(t, "quiet", modes[3].Key)
|
|
}
|
|
|
|
func TestAgentProviderSwitcher(t *testing.T) {
|
|
a := &Agent{workDir: "/tmp", activeIdx: -1}
|
|
|
|
providers := []core.ProviderConfig{
|
|
{Name: "moonshot", APIKey: "sk-123"},
|
|
{Name: "custom", BaseURL: "https://api.example.com"},
|
|
}
|
|
a.SetProviders(providers)
|
|
|
|
assert.False(t, a.SetActiveProvider("missing"))
|
|
assert.True(t, a.SetActiveProvider("moonshot"))
|
|
assert.Equal(t, "moonshot", a.GetActiveProvider().Name)
|
|
|
|
list := a.ListProviders()
|
|
require.Len(t, list, 2)
|
|
assert.Equal(t, "moonshot", list[0].Name)
|
|
}
|
|
|
|
func TestAgentStartSession(t *testing.T) {
|
|
skipUnlessKimiAvailable(t)
|
|
agentInf, err := New(map[string]any{
|
|
"work_dir": "/tmp",
|
|
"model": "kimi-k2",
|
|
"mode": "default",
|
|
"timeout_mins": 10,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := agentInf.StartSession(ctx, "test-session-id")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, session)
|
|
assert.True(t, session.Alive())
|
|
assert.Equal(t, "test-session-id", session.CurrentSessionID())
|
|
|
|
err = session.Close()
|
|
assert.NoError(t, err)
|
|
assert.False(t, session.Alive())
|
|
}
|
|
|
|
func TestAgentMemoryAndSkill(t *testing.T) {
|
|
a := &Agent{workDir: "/tmp/my-project", activeIdx: -1}
|
|
|
|
assert.Equal(t, "/tmp/my-project/AGENTS.md", a.ProjectMemoryFile())
|
|
assert.NotEmpty(t, a.GlobalMemoryFile())
|
|
|
|
skillDirs := a.SkillDirs()
|
|
require.Len(t, skillDirs, 2)
|
|
assert.Contains(t, skillDirs[0], ".kimi/skills")
|
|
assert.Contains(t, skillDirs[1], ".kimi/skills")
|
|
}
|
|
|
|
func TestAgentAvailableModels(t *testing.T) {
|
|
a := &Agent{workDir: "/tmp", activeIdx: -1}
|
|
|
|
models := a.AvailableModels(context.Background())
|
|
require.True(t, len(models) > 0)
|
|
}
|
|
|
|
// TestListKimiSessions_BothFlavors is the #1561 session-listing regression
|
|
// test: sessions created by legacy kimi-cli (~/.kimi/sessions) and by the
|
|
// Kimi Code CLI (~/.kimi-code/sessions) must both be visible, and the
|
|
// Kimi Code state.json schema ({"title","workDir"}) must be understood.
|
|
func TestListKimiSessions_BothFlavors(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
workDir := t.TempDir()
|
|
|
|
// Legacy kimi-cli session.
|
|
legacyDir := filepath.Join(home, ".kimi", "sessions", "proj-hash", "legacy-uuid-1")
|
|
require.NoError(t, os.MkdirAll(legacyDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(legacyDir, "state.json"),
|
|
[]byte(`{"custom_title":"legacy chat","archived":false}`), 0o644))
|
|
|
|
// Kimi Code CLI session in the same workDir.
|
|
modernDir := filepath.Join(home, ".kimi-code", "sessions", "wd_proj_ab12", "session_modern-1")
|
|
require.NoError(t, os.MkdirAll(modernDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(modernDir, "state.json"),
|
|
[]byte(`{"title":"modern chat","workDir":"`+workDir+`"}`), 0o644))
|
|
// Kimi Code stores the transcript at agents/main/wire.jsonl (no
|
|
// context.jsonl). Include tool/assistant events that must NOT be counted.
|
|
require.NoError(t, os.MkdirAll(filepath.Join(modernDir, "agents", "main"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(modernDir, "agents", "main", "wire.jsonl"), []byte(
|
|
`{"type":"context.append_message","message":{"role":"user","content":"first ask"},"origin":{"kind":"user"}}
|
|
`+
|
|
`{"type":"context.append_message","message":{"role":"assistant","content":"thinking..."},"origin":{"kind":"assistant"}}
|
|
`+
|
|
`{"type":"context.append_message","message":{"role":"tool","content":"result"},"origin":{"kind":"tool_call_executor"}}
|
|
`+
|
|
`{"type":"context.append_message","message":{"role":"user","content":"second ask"},"origin":{"kind":"user"}}
|
|
`+
|
|
`{"type":"context.append_message","message":{"role":"user","content":"third ask"},"origin":{"kind":"user"}}
|
|
`), 0o644))
|
|
|
|
// Kimi Code CLI session belonging to a DIFFERENT workDir — filtered out.
|
|
otherDir := filepath.Join(home, ".kimi-code", "sessions", "wd_proj_ab12", "session_other-1")
|
|
require.NoError(t, os.MkdirAll(otherDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(otherDir, "state.json"),
|
|
[]byte(`{"title":"elsewhere","workDir":"/somewhere/else"}`), 0o644))
|
|
|
|
sessions, err := listKimiSessions(workDir)
|
|
require.NoError(t, err)
|
|
|
|
byID := make(map[string]core.AgentSessionInfo, len(sessions))
|
|
for _, s := range sessions {
|
|
byID[s.ID] = s
|
|
}
|
|
|
|
legacy, ok := byID["legacy-uuid-1"]
|
|
require.True(t, ok, "legacy kimi-cli session should be listed")
|
|
assert.Equal(t, "legacy chat", legacy.Summary)
|
|
|
|
modern, ok := byID["session_modern-1"]
|
|
require.True(t, ok, "Kimi Code session should be listed")
|
|
// Summary now comes from the first user turn in wire.jsonl (matching the
|
|
// legacy behavior of summarizing from the transcript), falling back to the
|
|
// state title only when no transcript exists.
|
|
assert.Equal(t, "first ask", modern.Summary)
|
|
// Regression (#1564 review): Kimi Code sessions must not report 0 messages —
|
|
// the count comes from agents/main/wire.jsonl when context.jsonl is absent.
|
|
assert.Equal(t, 3, modern.MessageCount, "Kimi Code session should count user turns from wire.jsonl")
|
|
|
|
_, ok = byID["session_other-1"]
|
|
assert.False(t, ok, "Kimi Code session from another workDir must be filtered out")
|
|
|
|
// findKimiSessionDir must locate sessions in both roots.
|
|
assert.NotEmpty(t, findKimiSessionDir("legacy-uuid-1"))
|
|
assert.NotEmpty(t, findKimiSessionDir("session_modern-1"))
|
|
assert.Empty(t, findKimiSessionDir("does-not-exist"))
|
|
}
|
|
|
|
// Regression for #1564 review feedback: /list reported 0 messages for every
|
|
// Kimi Code session because the count only read context.jsonl, which the Kimi
|
|
// Code CLI does not write. This test pins the wire.jsonl fallback: user turns
|
|
// are counted, tool/assistant events are ignored, and the first user text
|
|
// becomes the summary.
|
|
func TestParseKimiSessionDir_WireJSONLMessageCount(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
workDir := t.TempDir()
|
|
sessionID := "session_wire-1"
|
|
sessionDir := filepath.Join(home, ".kimi-code", "sessions", "wd_proj_ab12", sessionID)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(sessionDir, "agents", "main"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "state.json"),
|
|
[]byte(`{"title":"wire chat","workDir":"`+workDir+`"}`), 0o644))
|
|
|
|
// Simulate a wire transcript: two user turns, some assistant/tool noise,
|
|
// punctuated by a non-append event that must be skipped.
|
|
wire := []byte(
|
|
`{"type":"context.append_message","message":{"role":"user","content":" hello there "},"origin":{"kind":"user"}}
|
|
` +
|
|
`{"type":"context.append_message","message":{"role":"assistant","content":"hi, inspect <thinking>"},"origin":{"kind":"assistant"}}
|
|
` +
|
|
`{"type":"context.append_message","message":{"role":"tool","content":"ls output"},"origin":{"kind":"tool_call_executor"}}
|
|
` +
|
|
`{"type":"session.started","session_id":"` + sessionID + `"}
|
|
` +
|
|
`{"type":"context.append_message","message":{"role":"user","content":"now list sessions"},"origin":{"kind":"user"}}
|
|
`)
|
|
require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "agents", "main", "wire.jsonl"), wire, 0o644))
|
|
|
|
info := parseKimiSessionDir(sessionDir, workDir)
|
|
require.NotNil(t, info)
|
|
assert.Equal(t, sessionID, info.ID)
|
|
assert.Equal(t, 2, info.MessageCount,
|
|
"only user turns should be counted from wire.jsonl")
|
|
assert.Equal(t, "hello there", info.Summary,
|
|
"summary should be the first user text, trimmed")
|
|
}
|