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>
This commit is contained in:
8 files changed
+797
-122
No files matched your search
+2
-1
@@ -1,4 +1,4 @@
|
||||
# Changelog
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- **feishu**: coalesce consecutive image messages from the same session into a single multi-image dispatch to fix first-image drop on batch sends (#1395). When the Feishu mobile client sends N images in quick succession, each image arrives as a separate `image` event with very close `create_time` values. Dispatching each immediately caused core/engine's `create_time` watermark (PR #1168) to drop the oldest image, so the agent only saw N-1 images. A per-session image buffer with a 150ms quiet window now merges the burst into one `core.Message` carrying all images, in send order. Single-image sends and quoted-image replies are unaffected.
|
||||
- **claudecode**: fix per-spawn system-prompt temp file EACCES under `run_as_user` (#1429). The per-spawn temp file written by `writeTempAppendPromptFile` (the 1% edge-case path used when the prompt has session-specific platform formatting or user `append_system_prompt`) inherited `os.CreateTemp`'s 0600 mode and was owned by the cc-connect process user (often root under systemd). When the agent was spawned under a different `run_as_user`, it could not read the file and exited before any prompt was loaded. The file is now `chmod 0o644` immediately after write, matching the shared `ensureSharedSystemPromptFile` path. Prompt content is non-secret (a superset of the already-shared base prompt), so 0644 is consistent with the shared file. Does not affect the shared-file path (already 0644 since #1376) or the daemon-mode path resolution (#1419).
|
||||
- **kimi**: gate `--work-dir` flag on `kimiFlagSupport.WorkDir` so the Kimi Code CLI build that dropped the flag no longer exits with `error: unknown option --work-dir` (#1476). Same probe-based pattern as the `--print` fix in #1456 / PR #1461: the agent probes `kimi --help` once at construction, then `buildArgs` emits the flag only when the installed CLI advertises it. The agent still runs in the correct directory because `exec.Command.Dir` is set separately, so legacy kimi-cli users (who still have `--work-dir`) keep non-default workspace support while modern-CLI users get clean startup.
|
||||
- **kimi**: native Kimi Code CLI (Node.js `kimi-code`) dialect support (#1561). The newer CLI removed `--quiet` and `--resume`, moved the session resume hint from a plain-text line to a stdout JSON meta event (`{"role":"meta","type":"session.resume_hint",...}`), and emits assistant/tool `content` as a plain string instead of typed blocks — causing `error: unknown option`, silently dropped replies, and broken multi-turn continuity. The probe now also gates `--quiet` (emulated locally by suppressing thinking/tool events when unsupported), resume uses `-r <id>` on the modern dialect (the command the CLI's own hint prints), the meta resume-hint event restores the session ID, and the stream parser accepts both content shapes regardless of probed flavor. Session listing additionally scans `~/.kimi-code/sessions` with its `state.json` schema (`title`/`workDir`, honoring the workDir filter) and counts messages from `agents/main/wire.jsonl` so `/list` and `/delete` work for both CLI flavors. Legacy kimi-cli behavior is unchanged.
|
||||
- **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free.
|
||||
- **core**: `SaveFilesToDisk` / `AppendFileRefs` always emit absolute paths (#1459). When a user configured a relative `work_dir` (e.g. `~/project` or `.cc-connect`), `SaveFilesToDisk` joined relative paths into the attachments directory and the resulting paths were passed verbatim into the agent's prompt. The spawned agent process — typically run from a different cwd by the platform adapter — could not resolve them and silently dropped every attachment. `SaveFilesToDisk` now calls `filepath.Abs(workDir)` up front and falls back to the raw value on error, and `AppendFileRefs` defensively absolutizes each entry. Both behaviors are covered by new tests for relative, absolute, and empty workDir; the empty-workDir case falls back to the process cwd so misconfigured deploys still get a writable attachments directory.
|
||||
|
||||
|
||||
+164
-75
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -35,7 +36,8 @@ func init() {
|
||||
// - "default": standard mode (non-interactive `--prompt` auto-approves tools)
|
||||
// - "yolo": auto-approve all tool calls
|
||||
// - "plan": read-only plan mode
|
||||
// - "quiet": alias for --quiet (print + text + final-message-only)
|
||||
// - "quiet": final-message-only; uses --quiet on legacy kimi-cli, local
|
||||
// event suppression on the Kimi Code CLI (which dropped --quiet, #1561)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
@@ -325,12 +327,19 @@ func (a *Agent) providerEnvLocked() []string {
|
||||
|
||||
// ── Session listing ─────────────────────────────────────────────
|
||||
|
||||
func kimiSessionsBaseDir() string {
|
||||
// kimiSessionsBaseDirs returns the session storage roots of both CLI
|
||||
// flavors: legacy kimi-cli keeps sessions under ~/.kimi/sessions, the Kimi
|
||||
// Code CLI uses ~/.kimi-code/sessions (#1561). Both are scanned so /list and
|
||||
// /delete work no matter which binary produced the session.
|
||||
func kimiSessionsBaseDirs() []string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
filepath.Join(homeDir, ".kimi", "sessions"),
|
||||
filepath.Join(homeDir, ".kimi-code", "sessions"),
|
||||
}
|
||||
return filepath.Join(homeDir, ".kimi", "sessions")
|
||||
}
|
||||
|
||||
func listKimiSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
@@ -339,37 +348,34 @@ func listKimiSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
absWorkDir = workDir
|
||||
}
|
||||
|
||||
sessionsBase := kimiSessionsBaseDir()
|
||||
if sessionsBase == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sessionsBase)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("kimi: read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
projectDir := filepath.Join(sessionsBase, entry.Name())
|
||||
sessionEntries, err := os.ReadDir(projectDir)
|
||||
for _, sessionsBase := range kimiSessionsBaseDirs() {
|
||||
entries, err := os.ReadDir(sessionsBase)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
sessionDir := filepath.Join(projectDir, se.Name())
|
||||
info := parseKimiSessionDir(sessionDir, absWorkDir)
|
||||
if info != nil {
|
||||
sessions = append(sessions, *info)
|
||||
return nil, fmt.Errorf("kimi: read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
projectDir := filepath.Join(sessionsBase, entry.Name())
|
||||
sessionEntries, err := os.ReadDir(projectDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
continue
|
||||
}
|
||||
sessionDir := filepath.Join(projectDir, se.Name())
|
||||
info := parseKimiSessionDir(sessionDir, absWorkDir)
|
||||
if info != nil {
|
||||
sessions = append(sessions, *info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,6 +387,95 @@ func listKimiSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// parseKimiTranscript counts a session's conversation messages and extracts a
|
||||
// summary from its transcript file. The legacy kimi-cli writes the transcript
|
||||
// to context.jsonl; the Kimi Code CLI instead stores it at
|
||||
// agents/main/wire.jsonl (#1561). We read whichever exists so /list does not
|
||||
// report 0 messages for modern sessions (review feedback on #1564).
|
||||
func parseKimiTranscript(sessionDir string) (msgCount int, summary string) {
|
||||
contextPath := filepath.Join(sessionDir, "context.jsonl")
|
||||
if f, err := os.Open(contextPath); err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
msgCount, summary = countContextJSONL(f)
|
||||
}
|
||||
if msgCount == 0 {
|
||||
// Kimi Code CLI fallback — no context.jsonl, so count from wire.jsonl.
|
||||
wirePath := filepath.Join(sessionDir, "agents", "main", "wire.jsonl")
|
||||
if f, err := os.Open(wirePath); err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
m, s := countWireJSONL(f)
|
||||
if m > msgCount {
|
||||
msgCount = m
|
||||
}
|
||||
if summary == "" {
|
||||
summary = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgCount, summary
|
||||
}
|
||||
|
||||
// countContextJSONL parses a legacy kimi-cli context.jsonl transcript,
|
||||
// counting user/assistant messages and taking the first user text as summary.
|
||||
func countContextJSONL(f io.Reader) (msgCount int, summary string) {
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
var entry struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Role == "user" || entry.Role == "assistant" {
|
||||
msgCount++
|
||||
if entry.Role == "user" && entry.Content != "" && summary == "" {
|
||||
summary = strings.TrimSpace(entry.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgCount, summary
|
||||
}
|
||||
|
||||
// countWireJSONL parses a Kimi Code CLI agents/main/wire.jsonl transcript.
|
||||
// Each line is an event such as
|
||||
//
|
||||
// {"type":"context.append_message","message":{"role":"user","content":...},
|
||||
// "origin":{"kind":"user",...}}
|
||||
//
|
||||
// We count only user-side turns (origin.kind == "user") rather than every
|
||||
// appended event, which would also include tool results and streamed
|
||||
// assistant chunks. This matches the reviewer signal for "how many messages
|
||||
// are in this session" and keeps the count stable.
|
||||
func countWireJSONL(f io.Reader) (msgCount int, summary string) {
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
var entry struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Origin struct {
|
||||
Kind string `json:"kind"`
|
||||
} `json:"origin"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Type != "context.append_message" || entry.Origin.Kind != "user" {
|
||||
continue
|
||||
}
|
||||
msgCount++
|
||||
if entry.Message.Content != "" && summary == "" {
|
||||
summary = strings.TrimSpace(entry.Message.Content)
|
||||
}
|
||||
}
|
||||
return msgCount, summary
|
||||
}
|
||||
|
||||
func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInfo {
|
||||
statePath := filepath.Join(sessionDir, "state.json")
|
||||
stateData, err := os.ReadFile(statePath)
|
||||
@@ -388,9 +483,14 @@ func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInf
|
||||
return nil
|
||||
}
|
||||
|
||||
// Two state.json schemas (#1561): legacy kimi-cli stores
|
||||
// {"custom_title","archived"}; the Kimi Code CLI stores
|
||||
// {"title","workDir","updatedAt",...} with no archived flag.
|
||||
var state struct {
|
||||
CustomTitle string `json:"custom_title"`
|
||||
Title string `json:"title"`
|
||||
Archived bool `json:"archived"`
|
||||
WorkDir string `json:"workDir"`
|
||||
}
|
||||
if json.Unmarshal(stateData, &state) != nil {
|
||||
return nil
|
||||
@@ -399,6 +499,19 @@ func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInf
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Kimi Code CLI records the session's workDir, so unlike the legacy
|
||||
// flavor (which stores no cwd and is always listed) we can honor the
|
||||
// caller's workDir filter for it.
|
||||
if state.WorkDir != "" && filterWorkDir != "" {
|
||||
absStateDir, err := filepath.Abs(state.WorkDir)
|
||||
if err != nil {
|
||||
absStateDir = state.WorkDir
|
||||
}
|
||||
if absStateDir != filterWorkDir {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
sessionID := filepath.Base(sessionDir)
|
||||
|
||||
info, err := os.Stat(sessionDir)
|
||||
@@ -406,35 +519,14 @@ func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInf
|
||||
return nil
|
||||
}
|
||||
|
||||
msgCount := 0
|
||||
summary := ""
|
||||
contextPath := filepath.Join(sessionDir, "context.jsonl")
|
||||
if f, err := os.Open(contextPath); err == nil {
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
var entry struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Role == "user" || entry.Role == "assistant" {
|
||||
msgCount++
|
||||
if entry.Role == "user" && entry.Content != "" && summary == "" {
|
||||
summary = strings.TrimSpace(entry.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = filterWorkDir // Kimi does not store cwd in session metadata; list all sessions.
|
||||
msgCount, summary := parseKimiTranscript(sessionDir)
|
||||
|
||||
if summary == "" {
|
||||
summary = state.CustomTitle
|
||||
}
|
||||
if summary == "" {
|
||||
summary = state.Title
|
||||
}
|
||||
if utf8.RuneCountInString(summary) > 60 {
|
||||
summary = string([]rune(summary)[:60]) + "..."
|
||||
}
|
||||
@@ -448,30 +540,27 @@ func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInf
|
||||
}
|
||||
|
||||
func findKimiSessionDir(sessionID string) string {
|
||||
sessionsBase := kimiSessionsBaseDir()
|
||||
if sessionsBase == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sessionsBase)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
projectDir := filepath.Join(sessionsBase, entry.Name())
|
||||
sessionEntries, err := os.ReadDir(projectDir)
|
||||
for _, sessionsBase := range kimiSessionsBaseDirs() {
|
||||
entries, err := os.ReadDir(sessionsBase)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if se.Name() == sessionID {
|
||||
return filepath.Join(projectDir, se.Name())
|
||||
projectDir := filepath.Join(sessionsBase, entry.Name())
|
||||
sessionEntries, err := os.ReadDir(projectDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
continue
|
||||
}
|
||||
if se.Name() == sessionID {
|
||||
return filepath.Join(projectDir, se.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package kimi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -161,3 +163,116 @@ func TestAgentAvailableModels(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package kimi
|
||||
|
||||
// Live end-to-end test against a real Kimi Code CLI binary.
|
||||
// NOT for CI — run manually with:
|
||||
//
|
||||
// KIMI_LIVE_E2E=1 KIMI_BIN=$HOME/.kimi-code/bin/kimi go test ./agent/kimi/ -run TestLiveE2E -v -timeout 5m
|
||||
//
|
||||
// Verifies the full #1561 acceptance chain against the production binary:
|
||||
// probe -> arg dialect -> meta resume-hint capture -> -r session continuity.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func liveKimiBin(t *testing.T) string {
|
||||
t.Helper()
|
||||
if os.Getenv("KIMI_LIVE_E2E") != "1" {
|
||||
t.Skip("set KIMI_LIVE_E2E=1 to run the live e2e against a real Kimi Code CLI")
|
||||
}
|
||||
bin := os.Getenv("KIMI_BIN")
|
||||
if bin == "" {
|
||||
bin = "kimi"
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func runTurn(t *testing.T, s core.AgentSession, prompt string) string {
|
||||
t.Helper()
|
||||
require.NoError(t, s.Send(prompt, "", nil, nil))
|
||||
|
||||
var text strings.Builder
|
||||
deadline := time.After(150 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-s.Events():
|
||||
if !ok {
|
||||
return text.String()
|
||||
}
|
||||
switch evt.Type {
|
||||
case core.EventText:
|
||||
text.WriteString(evt.Content)
|
||||
case core.EventThinking:
|
||||
t.Logf("[thinking] %.120s", evt.Content)
|
||||
case core.EventToolUse:
|
||||
t.Logf("[tool] %s %.80s", evt.ToolName, evt.ToolInput)
|
||||
case core.EventError:
|
||||
t.Fatalf("turn error: %v", evt.Error)
|
||||
case core.EventResult:
|
||||
return text.String()
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("turn timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveE2E_KimiCodeFlavor(t *testing.T) {
|
||||
bin := liveKimiBin(t)
|
||||
|
||||
agentInf, err := New(map[string]any{
|
||||
"cmd": bin,
|
||||
"work_dir": t.TempDir(),
|
||||
"timeout_mins": 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
a := agentInf.(*Agent)
|
||||
|
||||
// 1. Probe: real Kimi Code CLI v0.26 -> modern surface (all false).
|
||||
assert.False(t, a.flagSupport.Print, "kimi-code must not probe --print support")
|
||||
assert.False(t, a.flagSupport.WorkDir, "kimi-code must not probe --work-dir support")
|
||||
assert.False(t, a.flagSupport.Quiet, "kimi-code must not probe --quiet support")
|
||||
assert.True(t, a.flagSupport.isModernFlavor())
|
||||
|
||||
ctx := context.Background()
|
||||
s, err := a.StartSession(ctx, "")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
// 2. Turn 1: establish a memory anchor.
|
||||
out1 := runTurn(t, s, "Remember the number 7391. Reply with exactly: remembered")
|
||||
t.Logf("turn1 text: %q", out1)
|
||||
sid := s.CurrentSessionID()
|
||||
require.NotEmpty(t, sid, "meta resume hint must populate the session id after turn 1")
|
||||
assert.True(t, strings.HasPrefix(sid, "session_"), "kimi-code session id shape, got %q", sid)
|
||||
|
||||
// 3. Turn 2: resume with -r and recall the anchor.
|
||||
out2 := runTurn(t, s, "What is the number I asked you to remember? Reply with just the number.")
|
||||
t.Logf("turn2 text: %q", out2)
|
||||
assert.Contains(t, out2, "7391", "resumed session must recall the anchor from turn 1")
|
||||
assert.Equal(t, sid, s.CurrentSessionID(), "session id must stay stable across resume")
|
||||
}
|
||||
|
||||
func TestLiveE2E_KimiCodeQuietArgs(t *testing.T) {
|
||||
bin := liveKimiBin(t)
|
||||
agentInf, err := New(map[string]any{"cmd": bin, "work_dir": t.TempDir()})
|
||||
require.NoError(t, err)
|
||||
a := agentInf.(*Agent)
|
||||
|
||||
ks, err := newKimiSession(context.Background(), a.cmd, nil, "/tmp", "", "quiet", "session_x", nil, 0, a.flagSupport)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
args := ks.buildArgs("hello")
|
||||
joined := strings.Join(args, " ")
|
||||
for _, banned := range []string{"--print", "--quiet", "--work-dir", "--resume", "--yolo", "--auto"} {
|
||||
assert.NotContains(t, joined, banned, "kimi-code rejects %s", banned)
|
||||
}
|
||||
assert.Contains(t, args, "-r")
|
||||
assert.Contains(t, args, "--output-format")
|
||||
assert.Contains(t, args, "--prompt")
|
||||
}
|
||||
+16
-4
@@ -20,13 +20,24 @@ import (
|
||||
// now produces: `error: unknown option '--print' (Did you mean --prompt?)`.
|
||||
// The same CLI release also dropped `--work-dir` (the working directory is
|
||||
// now taken from the process cwd only) and rejects the flag with `error:
|
||||
// unknown option --work-dir`. The older kimi-cli still requires `--print`
|
||||
// for `--output-format` to take effect and exposes `--work-dir` for
|
||||
// non-default workspace locations. We probe the help text once and adapt
|
||||
// accordingly. See #1456, #1476.
|
||||
// unknown option --work-dir`, and dropped `--quiet` as well. The older
|
||||
// kimi-cli still requires `--print` for `--output-format` to take effect
|
||||
// and exposes `--work-dir` for non-default workspace locations. We probe
|
||||
// the help text once and adapt accordingly. See #1456, #1476, #1561.
|
||||
type kimiFlagSupport struct {
|
||||
Print bool
|
||||
WorkDir bool
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
// isModernFlavor reports whether the probed binary speaks the newer Kimi
|
||||
// Code CLI dialect. `--print` is the family discriminator established in
|
||||
// #1456: every Kimi Code CLI build drops it, every legacy kimi-cli build
|
||||
// advertises it. Dialect-level differences that cannot be help-probed
|
||||
// reliably (e.g. `--resume` vs `-r`, which legacy kimi-cli accepts but does
|
||||
// not always advertise) branch on this. See #1561.
|
||||
func (s kimiFlagSupport) isModernFlavor() bool {
|
||||
return !s.Print
|
||||
}
|
||||
|
||||
// probeKimiFlags runs `<cmd> --help` with a short timeout and returns the
|
||||
@@ -56,6 +67,7 @@ func probeKimiFlags(parent context.Context, cmd string, timeout time.Duration) k
|
||||
support := kimiFlagSupport{
|
||||
Print: flags["--print"],
|
||||
WorkDir: flags["--work-dir"],
|
||||
Quiet: flags["--quiet"],
|
||||
}
|
||||
slog.Debug("kimi: flag probe complete", "cmd", cmd, "support", support)
|
||||
return support
|
||||
|
||||
@@ -163,3 +163,72 @@ func TestKimiFlagSupport_ModernWithoutWorkDir(t *testing.T) {
|
||||
assert.False(t, support.WorkDir,
|
||||
"probe must NOT surface work-dir support on CLI builds that dropped it (#1476)")
|
||||
}
|
||||
|
||||
// kimiCodeHelpV026 is the verbatim `kimi --help` output of the Kimi Code CLI
|
||||
// v0.26.0 (Node.js, commander.js layout) — the build whose incompatibility
|
||||
// with the kimi agent dialect is tracked in #1561. It advertises neither
|
||||
// --print, nor --work-dir, nor --quiet, nor --resume.
|
||||
const kimiCodeHelpV026 = `
|
||||
Usage: kimi [options] [command]
|
||||
|
||||
The Starting Point for Next-Gen Agents
|
||||
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
-S, --session [id] Resume a session. With ID: resume that session. Without ID:
|
||||
interactively pick.
|
||||
-c, --continue Continue the previous session for the working directory. (default:
|
||||
false)
|
||||
-y, --yolo Automatically approve all actions. (default: false)
|
||||
--auto Start in auto permission mode. (default: false)
|
||||
-m, --model <model> LLM model alias to use for this invocation. Defaults to
|
||||
default_model in config.toml.
|
||||
-p, --prompt <prompt> Run one prompt non-interactively and print the response.
|
||||
--output-format <format> Output format for prompt mode. Defaults to text. (choices: "text",
|
||||
"stream-json")
|
||||
--skills-dir <dir> Load skills from this directory instead of auto-discovered user and
|
||||
project directories. Can be repeated. (default: [])
|
||||
--add-dir <dir> Add an additional workspace directory for this session. Can be
|
||||
repeated. (default: [])
|
||||
--plan Start in plan mode. (default: false)
|
||||
-h, --help Show help.
|
||||
|
||||
Commands:
|
||||
export [options] [sessionId] Export a session as a ZIP archive.
|
||||
provider Manage LLM providers non-interactively.
|
||||
acp [options] Run kimi-code as an Agent Client Protocol (ACP) server over stdio.
|
||||
`
|
||||
|
||||
// TestParseKimiHelpFlags_KimiCodeV026 anchors the probe to the real Kimi
|
||||
// Code CLI v0.26.0 help surface (#1561): none of the legacy-only flags may
|
||||
// be detected.
|
||||
func TestParseKimiHelpFlags_KimiCodeV026(t *testing.T) {
|
||||
flags := parseKimiHelpFlags(kimiCodeHelpV026)
|
||||
assert.False(t, flags["--print"], "kimi-code does not advertise --print")
|
||||
assert.False(t, flags["--work-dir"], "kimi-code does not advertise --work-dir")
|
||||
assert.False(t, flags["--quiet"], "kimi-code does not advertise --quiet")
|
||||
assert.False(t, flags["--resume"], "kimi-code does not advertise --resume")
|
||||
assert.True(t, flags["--prompt"], "kimi-code advertises --prompt")
|
||||
assert.True(t, flags["--output-format"], "kimi-code advertises --output-format")
|
||||
assert.True(t, flags["--plan"], "kimi-code advertises --plan")
|
||||
assert.True(t, flags["--session"], "kimi-code advertises --session")
|
||||
}
|
||||
|
||||
// TestKimiFlagSupport_QuietMapping covers the --quiet probe-mapping added in
|
||||
// #1561: legacy kimi-cli advertises --quiet, Kimi Code CLI does not.
|
||||
func TestKimiFlagSupport_QuietMapping(t *testing.T) {
|
||||
legacy := parseKimiHelpFlags(legacyKimiHelp)
|
||||
assert.True(t, legacy["--quiet"], "legacy help advertises --quiet")
|
||||
|
||||
modern := parseKimiHelpFlags(kimiCodeHelpV026)
|
||||
assert.False(t, modern["--quiet"], "kimi-code help must not advertise --quiet")
|
||||
}
|
||||
|
||||
// TestKimiFlagSupport_IsModernFlavor pins the family discriminator: presence
|
||||
// of --print means legacy kimi-cli dialect, absence means Kimi Code dialect.
|
||||
func TestKimiFlagSupport_IsModernFlavor(t *testing.T) {
|
||||
assert.False(t, kimiFlagSupport{Print: true}.isModernFlavor(),
|
||||
"--print advertised => legacy kimi-cli flavor")
|
||||
assert.True(t, kimiFlagSupport{}.isModernFlavor(),
|
||||
"no --print (incl. probe failure fallback) => modern Kimi Code flavor")
|
||||
}
|
||||
+109
-37
@@ -23,8 +23,9 @@ import (
|
||||
// kimSession manages multi-turn conversations with the Kimi CLI.
|
||||
// Each Send() launches a new `kimi --prompt` process (with `--print
|
||||
// --output-format stream-json` when the installed binary still supports
|
||||
// --print) and uses --resume for conversation continuity. The exact flag
|
||||
// surface is decided by the Agent's one-shot probe; see kimiFlagSupport.
|
||||
// --print) and resumes via `--resume` (legacy kimi-cli) or `-r` (Kimi Code
|
||||
// CLI, #1561) for conversation continuity. The exact flag surface is decided
|
||||
// by the Agent's one-shot probe; see kimiFlagSupport.
|
||||
type kimiSession struct {
|
||||
cmd string
|
||||
extraArgs []string // extra args from cmd, prepended before kimi args
|
||||
@@ -74,6 +75,16 @@ func newKimiSession(ctx context.Context, cmd string, extraArgs []string, workDir
|
||||
// tested without launching the CLI. The `--print` flag is only emitted when
|
||||
// the locally installed binary advertises it in `kimi --help`; the newer Kimi
|
||||
// Code CLI dropped that flag and rejects it outright (see issue #1456).
|
||||
//
|
||||
// Dialect notes (#1561):
|
||||
// - Session resume: legacy kimi-cli takes `--resume <id>`; the Kimi Code
|
||||
// CLI dialect uses `-r <id>` (the command its own resume hint prints).
|
||||
// - Quiet mode: passed through only when the binary advertises `--quiet`;
|
||||
// otherwise intermediate events are suppressed locally (see
|
||||
// suppressIntermediateEvents).
|
||||
// - Permission flags: never pass `--yolo`/`--auto`. Bare `--prompt` mode
|
||||
// auto-approves tools on both CLI flavors, and Kimi Code CLI rejects
|
||||
// `--prompt` combined with `--yolo`/`--auto`.
|
||||
func (ks *kimiSession) buildArgs(prompt string) []string {
|
||||
args := append([]string{}, ks.extraArgs...)
|
||||
if ks.flagSupport.Print {
|
||||
@@ -85,11 +96,17 @@ func (ks *kimiSession) buildArgs(prompt string) []string {
|
||||
case "plan":
|
||||
args = append(args, "--plan")
|
||||
case "quiet":
|
||||
args = append(args, "--quiet")
|
||||
if ks.flagSupport.Quiet {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
}
|
||||
|
||||
if sid := ks.CurrentSessionID(); sid != "" {
|
||||
args = append(args, "--resume", sid)
|
||||
if ks.flagSupport.isModernFlavor() {
|
||||
args = append(args, "-r", sid)
|
||||
} else {
|
||||
args = append(args, "--resume", sid)
|
||||
}
|
||||
}
|
||||
if ks.model != "" {
|
||||
args = append(args, "--model", ks.model)
|
||||
@@ -321,6 +338,12 @@ func extractResumeSessionID(line string) string {
|
||||
// Kimi CLI stream-json message roles:
|
||||
// - "assistant": content (think + text), tool_calls
|
||||
// - "tool": content (tool execution result), tool_call_id
|
||||
// - "meta": session metadata (Kimi Code CLI, e.g. session.resume_hint)
|
||||
//
|
||||
// Note on content shape (#1561): the legacy kimi-cli wraps message content in
|
||||
// typed blocks ([{"type":"text","text":...}]), while the Kimi Code CLI emits
|
||||
// content as a plain string. Both shapes are accepted regardless of probed
|
||||
// flavor so the parser stays tolerant of future format drift.
|
||||
func (ks *kimiSession) handleEvent(raw map[string]any) {
|
||||
role, _ := raw["role"].(string)
|
||||
|
||||
@@ -329,32 +352,72 @@ func (ks *kimiSession) handleEvent(raw map[string]any) {
|
||||
ks.handleAssistant(raw)
|
||||
case "tool":
|
||||
ks.handleTool(raw)
|
||||
case "meta":
|
||||
ks.handleMeta(raw)
|
||||
default:
|
||||
slog.Debug("kimiSession: unhandled role", "role", role)
|
||||
}
|
||||
}
|
||||
|
||||
func (ks *kimiSession) handleAssistant(raw map[string]any) {
|
||||
content, _ := raw["content"].([]any)
|
||||
for _, item := range content {
|
||||
block, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
// handleMeta processes Kimi Code CLI meta events. The CLI reports the
|
||||
// resumable session id as {"role":"meta","type":"session.resume_hint",...}
|
||||
// on stdout instead of the legacy plain-text/stderr hint; capturing it here
|
||||
// is what keeps multi-turn continuity working on the modern dialect (#1561).
|
||||
func (ks *kimiSession) handleMeta(raw map[string]any) {
|
||||
metaType, _ := raw["type"].(string)
|
||||
if metaType != "session.resume_hint" {
|
||||
slog.Debug("kimiSession: unhandled meta type", "type", metaType)
|
||||
return
|
||||
}
|
||||
id, _ := raw["session_id"].(string)
|
||||
if id == "" {
|
||||
// Fallback: the meta line also carries the legacy plain-text hint.
|
||||
if content, _ := raw["content"].(string); content != "" {
|
||||
id = extractResumeSessionID(content)
|
||||
}
|
||||
blockType, _ := block["type"].(string)
|
||||
switch blockType {
|
||||
case "think", "thinking":
|
||||
if think, ok := block["think"].(string); ok && think != "" {
|
||||
evt := core.Event{Type: core.EventThinking, Content: think}
|
||||
select {
|
||||
case ks.events <- evt:
|
||||
case <-ks.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if id != "" {
|
||||
ks.sessionID.Store(id)
|
||||
slog.Debug("kimiSession: session id from meta resume hint", "session_id", id)
|
||||
}
|
||||
}
|
||||
|
||||
// suppressIntermediateEvents reports whether quiet mode must be emulated
|
||||
// locally: the Kimi Code CLI dropped --quiet, so thinking/tool events are
|
||||
// filtered here and only the final text reaches the platform. When the
|
||||
// binary does advertise --quiet the CLI itself does the filtering.
|
||||
func (ks *kimiSession) suppressIntermediateEvents() bool {
|
||||
return ks.mode == "quiet" && !ks.flagSupport.Quiet
|
||||
}
|
||||
|
||||
func (ks *kimiSession) handleAssistant(raw map[string]any) {
|
||||
if text, ok := raw["content"].(string); ok {
|
||||
// Kimi Code CLI shape: content is a plain string (#1561).
|
||||
if text != "" {
|
||||
ks.pendingMsgs = append(ks.pendingMsgs, text)
|
||||
}
|
||||
} else {
|
||||
content, _ := raw["content"].([]any)
|
||||
for _, item := range content {
|
||||
block, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
case "text":
|
||||
if text, ok := block["text"].(string); ok && text != "" {
|
||||
ks.pendingMsgs = append(ks.pendingMsgs, text)
|
||||
blockType, _ := block["type"].(string)
|
||||
switch blockType {
|
||||
case "think", "thinking":
|
||||
if think, ok := block["think"].(string); ok && think != "" && !ks.suppressIntermediateEvents() {
|
||||
evt := core.Event{Type: core.EventThinking, Content: think}
|
||||
select {
|
||||
case ks.events <- evt:
|
||||
case <-ks.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
case "text":
|
||||
if text, ok := block["text"].(string); ok && text != "" {
|
||||
ks.pendingMsgs = append(ks.pendingMsgs, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +437,9 @@ func (ks *kimiSession) handleAssistant(raw map[string]any) {
|
||||
toolID, _ := tcMap["id"].(string)
|
||||
|
||||
slog.Debug("kimiSession: tool_call", "tool", toolName, "id", toolID)
|
||||
if ks.suppressIntermediateEvents() {
|
||||
continue
|
||||
}
|
||||
evt := core.Event{
|
||||
Type: core.EventToolUse,
|
||||
ToolName: toolName,
|
||||
@@ -391,23 +457,29 @@ func (ks *kimiSession) handleAssistant(raw map[string]any) {
|
||||
|
||||
func (ks *kimiSession) handleTool(raw map[string]any) {
|
||||
toolCallID, _ := raw["tool_call_id"].(string)
|
||||
content, _ := raw["content"].([]any)
|
||||
var outputParts []string
|
||||
for _, item := range content {
|
||||
block, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
blockType, _ := block["type"].(string)
|
||||
if blockType == "text" {
|
||||
if text, ok := block["text"].(string); ok {
|
||||
outputParts = append(outputParts, text)
|
||||
var output string
|
||||
if text, ok := raw["content"].(string); ok {
|
||||
// Kimi Code CLI shape: content is a plain string (#1561).
|
||||
output = text
|
||||
} else {
|
||||
content, _ := raw["content"].([]any)
|
||||
var outputParts []string
|
||||
for _, item := range content {
|
||||
block, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
blockType, _ := block["type"].(string)
|
||||
if blockType == "text" {
|
||||
if text, ok := block["text"].(string); ok {
|
||||
outputParts = append(outputParts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
output = strings.Join(outputParts, "")
|
||||
}
|
||||
output := strings.Join(outputParts, "")
|
||||
|
||||
if output != "" {
|
||||
if output != "" && !ks.suppressIntermediateEvents() {
|
||||
slog.Debug("kimiSession: tool result", "tool_call_id", toolCallID)
|
||||
evt := core.Event{
|
||||
Type: core.EventToolResult,
|
||||
@@ -428,7 +500,7 @@ func (ks *kimiSession) flushPendingAsThinking() {
|
||||
}
|
||||
text := strings.Join(ks.pendingMsgs, "")
|
||||
ks.pendingMsgs = ks.pendingMsgs[:0]
|
||||
if text != "" {
|
||||
if text != "" && !ks.suppressIntermediateEvents() {
|
||||
evt := core.Event{Type: core.EventThinking, Content: text}
|
||||
select {
|
||||
case ks.events <- evt:
|
||||
|
||||
+203
-5
@@ -255,11 +255,12 @@ func TestBuildArgs_PlanMode(t *testing.T) {
|
||||
assert.Contains(t, args, "kimi-k2")
|
||||
}
|
||||
|
||||
// TestBuildArgs_ResumeSession ensures session continuity flags are emitted
|
||||
// regardless of the --print probe result.
|
||||
func TestBuildArgs_ResumeSession(t *testing.T) {
|
||||
// TestBuildArgs_ResumeSession_LegacyDialect covers the legacy kimi-cli
|
||||
// resume flag: with --print advertised (legacy flavor), session continuity
|
||||
// must keep using `--resume <id>`.
|
||||
func TestBuildArgs_ResumeSession_LegacyDialect(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, err := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "sess-xyz", nil, 0, kimiFlagSupport{Print: false})
|
||||
ks, err := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "sess-xyz", nil, 0, kimiFlagSupport{Print: true})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
@@ -271,9 +272,206 @@ func TestBuildArgs_ResumeSession(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, resumeIdx, 0, "args should include --resume; got %v", args)
|
||||
require.GreaterOrEqual(t, resumeIdx, 0, "legacy args should include --resume; got %v", args)
|
||||
require.Less(t, resumeIdx+1, len(args), "--resume should be followed by an id")
|
||||
assert.Equal(t, "sess-xyz", args[resumeIdx+1])
|
||||
assert.NotContains(t, args, "-r", "legacy dialect must not use -r")
|
||||
}
|
||||
|
||||
// TestBuildArgs_ResumeSession_ModernDialect is the #1561 resume-flag
|
||||
// regression test: the Kimi Code CLI rejects `--resume` (unknown option) and
|
||||
// expects `-r <id>` — the same command its own resume hint prints
|
||||
// ("To resume this session: kimi -r <id>").
|
||||
func TestBuildArgs_ResumeSession_ModernDialect(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, err := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "session_abc-123", nil, 0, kimiFlagSupport{})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
args := ks.buildArgs("continue")
|
||||
for _, a := range args {
|
||||
if a == "--resume" {
|
||||
t.Fatalf("modern dialect must not emit --resume; args=%v", args)
|
||||
}
|
||||
}
|
||||
rIdx := -1
|
||||
for i, a := range args {
|
||||
if a == "-r" {
|
||||
rIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, rIdx, 0, "modern args should include -r; got %v", args)
|
||||
require.Less(t, rIdx+1, len(args), "-r should be followed by an id")
|
||||
assert.Equal(t, "session_abc-123", args[rIdx+1])
|
||||
}
|
||||
|
||||
// TestBuildArgs_QuietFlagGated covers the #1561 --quiet dialect difference:
|
||||
// legacy kimi-cli accepts --quiet; the Kimi Code CLI dropped it, so quiet
|
||||
// mode must be emulated by local event suppression instead of the flag.
|
||||
func TestBuildArgs_QuietFlagGated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
legacy, err := newKimiSession(ctx, "kimi", nil, "/tmp", "", "quiet", "", nil, 0,
|
||||
kimiFlagSupport{Print: true, Quiet: true})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = legacy.Close() }()
|
||||
assert.Contains(t, legacy.buildArgs("hi"), "--quiet",
|
||||
"legacy flavor should pass --quiet through")
|
||||
assert.False(t, legacy.suppressIntermediateEvents(),
|
||||
"legacy flavor lets the CLI do quiet filtering")
|
||||
|
||||
modern, err := newKimiSession(ctx, "kimi", nil, "/tmp", "", "quiet", "", nil, 0,
|
||||
kimiFlagSupport{})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = modern.Close() }()
|
||||
assert.NotContains(t, modern.buildArgs("hi"), "--quiet",
|
||||
"modern flavor must not emit --quiet (unknown option)")
|
||||
assert.True(t, modern.suppressIntermediateEvents(),
|
||||
"modern flavor emulates quiet mode by suppressing events locally")
|
||||
}
|
||||
|
||||
// TestHandleAssistantStringContent is the #1561 content-shape regression
|
||||
// test: the Kimi Code CLI emits assistant content as a plain string instead
|
||||
// of typed blocks; the text must still be buffered for the final reply.
|
||||
func TestHandleAssistantStringContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "OK",
|
||||
})
|
||||
|
||||
require.Len(t, ks.pendingMsgs, 1)
|
||||
assert.Equal(t, "OK", ks.pendingMsgs[0])
|
||||
}
|
||||
|
||||
// TestHandleAssistantStringContentWithToolCalls pins the Kimi Code CLI shape
|
||||
// where an assistant message carries plain-string content AND tool_calls in
|
||||
// the same event (#1561): the text must surface as a thinking event (via
|
||||
// flushPendingAsThinking) before the tool-use event, not be silently dropped.
|
||||
// Ported from #1586.
|
||||
func TestHandleAssistantStringContentWithToolCalls(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "Let me check",
|
||||
"tool_calls": []any{
|
||||
map[string]any{
|
||||
"id": "tool_1",
|
||||
"function": map[string]any{"name": "Shell", "arguments": `{"command":"ls"}`},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
events := drainEvents(ks.events, 2)
|
||||
require.Len(t, events, 2)
|
||||
assert.Equal(t, core.EventThinking, events[0].Type)
|
||||
assert.Equal(t, "Let me check", events[0].Content)
|
||||
assert.Equal(t, core.EventToolUse, events[1].Type)
|
||||
assert.Equal(t, "Shell", events[1].ToolName)
|
||||
}
|
||||
|
||||
// TestHandleToolStringContent covers the plain-string content shape for tool
|
||||
// results on the Kimi Code CLI (#1561).
|
||||
func TestHandleToolStringContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": "tool_abc",
|
||||
"content": "file written\n",
|
||||
})
|
||||
|
||||
events := drainEvents(ks.events, 1)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, core.EventToolResult, events[0].Type)
|
||||
assert.Contains(t, events[0].ToolResult, "file written")
|
||||
}
|
||||
|
||||
// TestHandleMetaResumeHint is the #1561 session-continuity regression test:
|
||||
// the Kimi Code CLI reports the resumable session id via a stdout JSON meta
|
||||
// line ({"role":"meta","type":"session.resume_hint",...}) instead of the
|
||||
// legacy plain-text/stderr hint, and cc-connect must capture it so the next
|
||||
// turn can resume.
|
||||
func TestHandleMetaResumeHint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
// Verbatim meta line captured from kimi-code v0.26.0.
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "meta",
|
||||
"type": "session.resume_hint",
|
||||
"session_id": "session_c85029b1-6449-4b1e-a25f-018acd258eeb",
|
||||
"command": "kimi -r session_c85029b1-6449-4b1e-a25f-018acd258eeb",
|
||||
"content": "To resume this session: kimi -r session_c85029b1-6449-4b1e-a25f-018acd258eeb",
|
||||
})
|
||||
assert.Equal(t, "session_c85029b1-6449-4b1e-a25f-018acd258eeb", ks.CurrentSessionID())
|
||||
|
||||
// Fallback path: no session_id field, parse the legacy hint in content.
|
||||
ks2, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "default", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks2.Close() }()
|
||||
ks2.handleEvent(map[string]any{
|
||||
"role": "meta",
|
||||
"type": "session.resume_hint",
|
||||
"content": "To resume this session: kimi -r session_fallback-id",
|
||||
})
|
||||
assert.Equal(t, "session_fallback-id", ks2.CurrentSessionID())
|
||||
|
||||
// Unrelated meta types must not disturb the session id.
|
||||
ks2.handleEvent(map[string]any{"role": "meta", "type": "some.other.meta"})
|
||||
assert.Equal(t, "session_fallback-id", ks2.CurrentSessionID())
|
||||
}
|
||||
|
||||
// TestQuietModeSuppressesIntermediateEvents verifies the local quiet-mode
|
||||
// emulation for the Kimi Code CLI (#1561): thinking, tool-use and
|
||||
// tool-result events are dropped, while the final text still gets through.
|
||||
func TestQuietModeSuppressesIntermediateEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ks, _ := newKimiSession(ctx, "kimi", nil, "/tmp", "", "quiet", "", nil, 0, kimiFlagSupport{})
|
||||
defer func() { _ = ks.Close() }()
|
||||
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{"type": "think", "think": "secret reasoning"},
|
||||
map[string]any{"type": "text", "text": "working on it"},
|
||||
},
|
||||
"tool_calls": []any{
|
||||
map[string]any{
|
||||
"id": "tool_1",
|
||||
"function": map[string]any{"name": "Shell", "arguments": `{"command":"ls"}`},
|
||||
},
|
||||
},
|
||||
})
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": "tool_1",
|
||||
"content": "listing...",
|
||||
})
|
||||
// Final assistant text of the turn (stays buffered until end-of-turn).
|
||||
ks.handleEvent(map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "done!",
|
||||
})
|
||||
|
||||
events := drainEvents(ks.events, 3)
|
||||
assert.Empty(t, events, "quiet mode must suppress thinking/tool events, got %v", events)
|
||||
|
||||
// The remaining buffered text flushes as the final message.
|
||||
ks.flushPendingAsText()
|
||||
final := drainEvents(ks.events, 1)
|
||||
require.Len(t, final, 1)
|
||||
assert.Equal(t, core.EventText, final[0].Type)
|
||||
assert.Equal(t, "done!", final[0].Content)
|
||||
}
|
||||
|
||||
func drainEvents(ch <-chan core.Event, max int) []core.Event {
|
||||
|
||||
Reference in new issue
Block a user