mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(agent): support steering running Kiro turns via _session/steer Kiro CLI 2.33.0 exposes an undocumented ACP extension, _session/steer, that queues a message and injects it into the in-flight session/prompt at the next tool or message boundary. Wire it up as the kiro backend's Session.Supplement (active only while the prompt is in flight, and only when the daemon negotiated task supplements), and gate kiro >= 2.33.0 in SupportsTaskSupplement. MUL-8126 Co-authored-by: multica-agent <[email protected]> * fix(supplement): allow kiro in server-side steering whitelists The capability handshake (StartAgentTaskWithSupplement), direct steering (CreateTaskSupplement) and composer reply binding (BindCommentTaskSupplement) each whitelist providers, and none included kiro, so a negotiated Kiro run never got a capability row and steering stayed unreachable. Add kiro to all three, regenerate sqlc, and cover kiro in the negotiation and both delivery path tests. Also widen the kiro supplement test's readiness wait, which was flaky under full-package parallel load. MUL-8126 Co-authored-by: multica-agent <[email protected]> * fix(supplement): allow grok in composer reply binding BindCommentTaskSupplement was the only steering whitelist without grok, so a reply from the normal composer to a running Grok turn fell back to the next run even though direct steering worked. Add grok and cover it in the composer binding test. MUL-8126 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
255 lines
8.1 KiB
Go
255 lines
8.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestTaskSupplementVersionGate(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
provider, version string
|
|
want bool
|
|
}{
|
|
{"codex", "codex-cli 0.100.0", true}, {"codex", "0.99.0", false},
|
|
{"claude", "2.1.110 (Claude Code)", true}, {"claude", "2.1.109", false},
|
|
{"grok", "1.0.14", true}, {"grok", "1.0.13", false}, {"grok", "0.2.120", false},
|
|
{"cursor", "2026.10.16-e3cb207", true}, {"cursor", "2026.10.14", true}, {"cursor", "2026.04.17-787b533", false},
|
|
{"kiro", "kiro-cli 2.33.0", true}, {"kiro", "2.34.1", true}, {"kiro", "kiro-cli 2.32.9", false},
|
|
{"codex", "", false}, {"claude", "dev", false}, {"kimi", "9.0.0", false},
|
|
} {
|
|
if got := SupportsTaskSupplement(tc.provider, tc.version); got != tc.want {
|
|
t.Errorf("%s %q: supported=%v, want %v", tc.provider, tc.version, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSemver(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want semver
|
|
wantErr bool
|
|
}{
|
|
{"2.0.0", semver{2, 0, 0}, false},
|
|
{"v2.1.100", semver{2, 1, 100}, false},
|
|
{"2.1.100 (Claude Code)", semver{2, 1, 100}, false},
|
|
{"codex-cli 0.118.0", semver{0, 118, 0}, false},
|
|
{"1.0.20", semver{1, 0, 20}, false},
|
|
{"invalid", semver{}, true},
|
|
{"", semver{}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := parseSemver(tt.input)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseSemver(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseSemver(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSemverLessThan(t *testing.T) {
|
|
tests := []struct {
|
|
a, b semver
|
|
want bool
|
|
}{
|
|
{semver{1, 0, 0}, semver{2, 0, 0}, true},
|
|
{semver{2, 0, 0}, semver{1, 0, 0}, false},
|
|
{semver{2, 0, 0}, semver{2, 1, 0}, true},
|
|
{semver{2, 1, 0}, semver{2, 0, 0}, false},
|
|
{semver{2, 1, 12}, semver{2, 1, 13}, true},
|
|
{semver{2, 1, 13}, semver{2, 1, 12}, false},
|
|
{semver{2, 0, 0}, semver{2, 0, 0}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
got := tt.a.lessThan(tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("%v.lessThan(%v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckMinCLIVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr error
|
|
}{
|
|
{"tagged release at minimum", "v0.2.21", nil},
|
|
{"tagged release above minimum", "0.3.1", nil},
|
|
{"previous tagged release below minimum", "v0.2.20", ErrCLIVersionTooOld},
|
|
{"tagged release below minimum", "v0.2.15", ErrCLIVersionTooOld},
|
|
{"empty string", "", ErrCLIVersionMissing},
|
|
{"unparsable", "not-a-version", ErrCLIVersionMissing},
|
|
{"git-describe dev build past old tag", "v0.2.15-235-gdaf0e935", nil},
|
|
{"git-describe dirty dev build", "v0.2.15-235-gdaf0e935-dirty", nil},
|
|
{"git-describe dev build past current tag", "v0.2.21-3-gabc1234", nil},
|
|
}
|
|
for _, tt := range tests {
|
|
err := CheckMinCLIVersion(tt.input)
|
|
if tt.wantErr == nil && err != nil {
|
|
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want nil", tt.name, tt.input, err)
|
|
}
|
|
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
|
|
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want %v", tt.name, tt.input, err, tt.wantErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckMinCLIVersionForQuickCreateFields(t *testing.T) {
|
|
if err := CheckMinCLIVersionFor("0.4.2", MinQuickCreateFieldsCLIVersion); !errors.Is(err, ErrCLIVersionTooOld) {
|
|
t.Fatalf("0.4.2 error = %v, want ErrCLIVersionTooOld", err)
|
|
}
|
|
if err := CheckMinCLIVersionFor("0.4.3", MinQuickCreateFieldsCLIVersion); err != nil {
|
|
t.Fatalf("0.4.3 error = %v, want nil", err)
|
|
}
|
|
if err := CheckMinCLIVersionFor("v0.4.2-7-gabc1234", MinQuickCreateFieldsCLIVersion); err != nil {
|
|
t.Fatalf("dev build error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractVersionLine(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
// wantRecognised is whether the semver scan matched, as opposed to the
|
|
// trimmed-raw fallback answering. Pinned per case because
|
|
// salvageProbeAnswer treats the two differently: only a recognised
|
|
// version is evidence that an incomplete read already holds the answer.
|
|
wantRecognised bool
|
|
}{
|
|
{
|
|
name: "bare semver",
|
|
raw: "0.42.0\n",
|
|
want: "0.42.0",
|
|
wantRecognised: true,
|
|
},
|
|
{
|
|
name: "claude full string preserved",
|
|
raw: "2.1.5 (Claude Code)\n",
|
|
want: "2.1.5 (Claude Code)",
|
|
wantRecognised: true,
|
|
},
|
|
{
|
|
name: "codex prefix preserved",
|
|
raw: "codex-cli 0.118.0\n",
|
|
want: "codex-cli 0.118.0",
|
|
wantRecognised: true,
|
|
},
|
|
// Reproduces #2516: gemini's Windows shim emits `chcp` output to stdout
|
|
// before the real version. The chcp line has no dotted-number form,
|
|
// so the semver scan skips it and picks up "0.42.0" from the next line.
|
|
{
|
|
name: "windows chcp prefix before version",
|
|
raw: "Active code page: 65001\n0.42.0\n",
|
|
want: "0.42.0",
|
|
wantRecognised: true,
|
|
},
|
|
{
|
|
name: "windows chcp prefix CRLF",
|
|
raw: "Active code page: 65001\r\n0.42.0\r\n",
|
|
want: "0.42.0",
|
|
wantRecognised: true,
|
|
},
|
|
{
|
|
name: "leading blank lines",
|
|
raw: "\n\n 0.42.0\n",
|
|
want: "0.42.0",
|
|
wantRecognised: true,
|
|
},
|
|
{
|
|
name: "non-semver output falls back to trimmed raw",
|
|
raw: " some-build-id \n",
|
|
want: "some-build-id",
|
|
wantRecognised: false,
|
|
},
|
|
// The shape the salvage gate turns on: a wrapper's progress line
|
|
// satisfies the fallback, so "non-empty" cannot mean "the version
|
|
// arrived". See TestDetectCLIVersionDoesNotSalvageABannerAsTheVersion.
|
|
{
|
|
name: "wrapper banner is not a recognised version",
|
|
raw: "initializing plugins\n",
|
|
want: "initializing plugins",
|
|
wantRecognised: false,
|
|
},
|
|
{
|
|
name: "empty input",
|
|
raw: "",
|
|
want: "",
|
|
wantRecognised: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, recognised := extractVersionLine(tt.raw)
|
|
if got != tt.want {
|
|
t.Errorf("extractVersionLine(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
if recognised != tt.wantRecognised {
|
|
t.Errorf("extractVersionLine(%q) recognised = %v, want %v", tt.raw, recognised, tt.wantRecognised)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMinVersion(t *testing.T) {
|
|
tests := []struct {
|
|
agentType string
|
|
version string
|
|
wantErr bool
|
|
}{
|
|
{"claude", "2.0.0", false},
|
|
{"claude", "2.1.100", false},
|
|
{"claude", "2.1.100 (Claude Code)", false},
|
|
{"claude", "v2.0.0", false},
|
|
{"claude", "1.0.128", true},
|
|
{"claude", "1.9.99", true},
|
|
{"antigravity", "agy version 1.1.10", false},
|
|
{"antigravity", "1.1.9", true},
|
|
{"claude", "invalid", true},
|
|
{"codex", "codex-cli 0.118.0", false},
|
|
{"codex", "codex-cli 0.100.0", false},
|
|
{"codex", "codex-cli 0.99.0", true},
|
|
{"codex", "codex-cli 0.50.0", true},
|
|
{"grok", "grok 0.2.93 (f00f96316d4b) [stable]", false},
|
|
{"grok", "0.2.89", false},
|
|
{"grok", "0.2.0", true},
|
|
{"grok", "0.1.9", true},
|
|
{"qoderclicn", "unverified-version-format", false},
|
|
{"qwen", "0.20.0", false},
|
|
{"qwen", "qwen 0.20.1", false},
|
|
{"qwen", "0.19.9", true},
|
|
{"dim", "0.3.10", false},
|
|
{"dim", "0.3.11", false},
|
|
{"dim", "0.4.0", false},
|
|
{"dim", "0.3.9", true},
|
|
{"mcode", "0.1.2", false},
|
|
{"mcode", "mcode 0.1.1", true},
|
|
{"zeroclaw", "zeroclaw 0.8.4", false},
|
|
{"zeroclaw", "0.8.0", false},
|
|
{"zeroclaw", "0.7.5", true},
|
|
{"zeroclaw", "invalid", true},
|
|
{"dim", "0.2.99", true},
|
|
{"dim", "invalid", true},
|
|
// opencode: 1.1.54 is the first build that honors TMPDIR/TMP/TEMP for
|
|
// Bun native-module extraction. 1.1.53 and 1.1.49 are the versions
|
|
// actually measured leaking into the shared temp dir (#8392); the CLI
|
|
// prints a bare semver, so there is no prefix to strip.
|
|
{"opencode", "1.1.54", false},
|
|
{"opencode", "1.1.55", false},
|
|
{"opencode", "1.18.30", false},
|
|
{"opencode", "1.1.53", true},
|
|
{"opencode", "1.1.49", true},
|
|
{"opencode", "0.15.0", true},
|
|
{"unknown", "1.0.0", false},
|
|
}
|
|
for _, tt := range tests {
|
|
err := CheckMinVersion(tt.agentType, tt.version)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("CheckMinVersion(%q, %q) error = %v, wantErr %v", tt.agentType, tt.version, err, tt.wantErr)
|
|
}
|
|
}
|
|
}
|