Files
Multica Eveandmultica-agent 2b64569138 MUL-8126: feat(agent): steer running Kiro turns via _session/steer (#9396)
* 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]>
2026-10-21 19:01:56 +08:00

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)
}
}
}