mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 10:56:45 +08:00
* MUL-8070: feat(cursor): support live-run steering via Cursor hooks Cursor's headless --print mode has no mid-turn input channel, but its postToolUse/postToolUseFailure hooks can return additional_context that reaches the model in the same turn. A steerable Cursor run now writes a managed .cursor/hooks.json whose command is the multica binary in a hidden relay mode; the relay forwards the hook event to a token-protected loopback endpoint owned by the run, which hands back pending guidance tagged with a per-run nonce. The prompt declares that tag so the model treats the context as genuine user guidance instead of a prompt injection. Steering is offered only for daemon-managed workdirs on Unix with cursor-agent 2026.10.14+, and the server allowlists cursor for capability negotiation, supplement creation and composer binding. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * MUL-8070: fix(cursor): confirm steering only on Cursor's receipt and gate on installable hooks - Count hook-delivered guidance as delivered only when the tool call's completed stream event echoes it in hookAdditionalContexts. Guidance Cursor dropped for the same call is retried at the next tool boundary; guidance without a receipt fails when the turn ends. - Offer steering only when the workdir can carry the managed hooks.json, and fail supplements immediately if installation still fails after negotiation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
369 lines
12 KiB
Go
369 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var testCursorHookCommand = []string{"/opt/multica tools/multica", CursorHookHelperArg}
|
|
|
|
func startTestCursorSupplements(t *testing.T, ctx context.Context) (*cursorSupplementSession, string) {
|
|
t.Helper()
|
|
cwd := t.TempDir()
|
|
s, err := startCursorSupplementSession(ctx, cwd, testCursorHookCommand, slog.Default())
|
|
if err != nil {
|
|
t.Fatalf("startCursorSupplementSession: %v", err)
|
|
}
|
|
t.Cleanup(s.close)
|
|
return s, cwd
|
|
}
|
|
|
|
func (s *cursorSupplementSession) testEnv(name string) string {
|
|
for _, kv := range s.env() {
|
|
if key, value, ok := strings.Cut(kv, "="); ok && key == name {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func relayCursorHook(t *testing.T, getenv func(string) string, payload map[string]any) map[string]any {
|
|
t.Helper()
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out strings.Builder
|
|
RunCursorHookRelay(strings.NewReader(string(data)), &out, getenv)
|
|
var reply map[string]any
|
|
if err := json.Unmarshal([]byte(out.String()), &reply); err != nil {
|
|
t.Fatalf("relay printed %q, want one JSON object: %v", out.String(), err)
|
|
}
|
|
return reply
|
|
}
|
|
|
|
func postToolUse(conversationID string) map[string]any {
|
|
return map[string]any{
|
|
"hook_event_name": "postToolUse",
|
|
"conversation_id": conversationID,
|
|
"tool_name": "Shell",
|
|
"tool_output": strings.Repeat("x", 1<<20),
|
|
}
|
|
}
|
|
|
|
// supplementAsync starts Supplement and waits until the input is queued, so a
|
|
// following hook observes it deterministically.
|
|
func supplementAsync(t *testing.T, s *cursorSupplementSession, ctx context.Context, text string) <-chan error {
|
|
t.Helper()
|
|
done := make(chan error, 1)
|
|
go func() { done <- s.supplement(ctx, text) }()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
s.mu.Lock()
|
|
queued := len(s.pending)
|
|
s.mu.Unlock()
|
|
if queued > 0 {
|
|
return done
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("supplement returned before a hook: %v", err)
|
|
default:
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("supplement was never queued")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementDeliversThroughHookRelay(t *testing.T) {
|
|
s, cwd := startTestCursorSupplements(t, t.Context())
|
|
|
|
data, err := os.ReadFile(filepath.Join(cwd, ".cursor", "hooks.json"))
|
|
if err != nil {
|
|
t.Fatalf("hooks.json not written: %v", err)
|
|
}
|
|
var hooks cursorHooksFile
|
|
if err := json.Unmarshal(data, &hooks); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantCommand := `'/opt/multica tools/multica' '` + CursorHookHelperArg + `'`
|
|
for _, event := range []string{"postToolUse", "postToolUseFailure"} {
|
|
if got := hooks.Hooks[event]; len(got) != 1 || got[0].Command != wantCommand {
|
|
t.Fatalf("%s hooks = %#v, want command %q", event, got, wantCommand)
|
|
}
|
|
}
|
|
|
|
if s.ready() {
|
|
t.Fatal("ready before the CLI reported its conversation")
|
|
}
|
|
if err := s.supplement(t.Context(), "early"); err == nil || !strings.Contains(err.Error(), "turn has not started") {
|
|
t.Fatalf("supplement before start = %v, want turn-not-started", err)
|
|
}
|
|
s.activate("conv-main")
|
|
if !s.ready() {
|
|
t.Fatal("not ready after activation")
|
|
}
|
|
|
|
done := supplementAsync(t, s, t.Context(), "Also update the changelog.")
|
|
reply := relayCursorHook(t, s.testEnv, postToolUse("conv-main"))
|
|
guidance, _ := reply["additional_context"].(string)
|
|
if !strings.HasPrefix(guidance, s.tag()+"\n") || !strings.HasSuffix(guidance, "Also update the changelog.") {
|
|
t.Fatalf("additional_context = %q", guidance)
|
|
}
|
|
if !strings.Contains(s.promptNote(), s.tag()) {
|
|
t.Fatal("prompt note does not declare the delivery tag")
|
|
}
|
|
assertSupplementWaiting(t, done)
|
|
if reply := relayCursorHook(t, s.testEnv, postToolUse("conv-main")); len(reply) != 0 {
|
|
t.Fatalf("second hook replayed claimed guidance: %#v", reply)
|
|
}
|
|
|
|
s.receipt("call-other", []string{"unrelated hook context"})
|
|
assertSupplementWaiting(t, done)
|
|
s.receipt("call-1", []string{guidance})
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("supplement = %v, want delivered", err)
|
|
}
|
|
}
|
|
|
|
func assertSupplementWaiting(t *testing.T, done <-chan error) {
|
|
t.Helper()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("supplement settled (%v) before Cursor echoed the guidance", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
type closedHookStdout struct{}
|
|
|
|
func (closedHookStdout) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
|
|
|
func TestCursorSupplementRelayFailureIsNotDelivery(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
done := supplementAsync(t, s, t.Context(), "Stop before making the next change.")
|
|
payload := postToolUse("conv-main")
|
|
payload["tool_use_id"] = "call-1\nfc_1"
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
RunCursorHookRelay(strings.NewReader(string(data)), closedHookStdout{}, s.testEnv)
|
|
assertSupplementWaiting(t, done)
|
|
|
|
// Cursor reports the call without the guidance, so it never reached the
|
|
// model and goes out again at the next tool boundary.
|
|
s.receipt("call-1", nil)
|
|
assertSupplementWaiting(t, done)
|
|
reply := relayCursorHook(t, s.testEnv, postToolUse("conv-main"))
|
|
guidance, _ := reply["additional_context"].(string)
|
|
if !strings.HasSuffix(guidance, "Stop before making the next change.") {
|
|
t.Fatalf("retried additional_context = %q", guidance)
|
|
}
|
|
s.receipt("call-2", []string{guidance})
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("supplement = %v, want delivered on retry", err)
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementRetriesDroppedGuidanceFirst(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
input := func(text string) *cursorSupplementInput {
|
|
return &cursorSupplementInput{ctx: t.Context(), text: text, done: make(chan error, 1)}
|
|
}
|
|
first, second := input("first"), input("second")
|
|
s.pending = []*cursorSupplementInput{first}
|
|
s.prepareHook(cursorHookRequest{Event: "postToolUseFailure", ConversationID: "conv-main", ToolUseID: "call-read"})
|
|
s.pending = append(s.pending, second)
|
|
|
|
s.receipt("call-read", []string{})
|
|
output := s.prepareHook(cursorHookRequest{Event: "postToolUse", ConversationID: "conv-main", ToolUseID: "call-next"})
|
|
if output["additional_context"] != "first\n\nsecond" {
|
|
t.Fatalf("retry hook = %q, want dropped guidance ahead of newer guidance", output["additional_context"])
|
|
}
|
|
s.receipt("call-next", []string{"first\n\nsecond"})
|
|
for _, in := range []*cursorSupplementInput{first, second} {
|
|
if err := <-in.done; err != nil {
|
|
t.Fatalf("%s = %v, want delivered", in.text, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementWithoutReceiptFailsAtTurnEnd(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
done := supplementAsync(t, s, ctx, "no receipt")
|
|
if reply := relayCursorHook(t, s.testEnv, postToolUse("conv-main")); len(reply) == 0 {
|
|
t.Fatal("hook received no guidance")
|
|
}
|
|
// Cancelling cannot withdraw guidance Cursor may already hold.
|
|
cancel()
|
|
assertSupplementWaiting(t, done)
|
|
s.end()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("unconfirmed supplement at turn end = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementIgnoresHooksItDoesNotOwn(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
done := supplementAsync(t, s, ctx, "main loop only")
|
|
|
|
subagent := postToolUse("conv-main")
|
|
subagent["parent_tool_call_id"] = "toolu_task"
|
|
preTool := postToolUse("conv-main")
|
|
preTool["hook_event_name"] = "preToolUse"
|
|
for name, payload := range map[string]map[string]any{
|
|
"subagent": subagent,
|
|
"other conversation": postToolUse("conv-other"),
|
|
"other event": preTool,
|
|
} {
|
|
if reply := relayCursorHook(t, s.testEnv, payload); len(reply) != 0 {
|
|
t.Fatalf("%s hook received guidance: %#v", name, reply)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancelled supplement = %v, want context.Canceled", err)
|
|
}
|
|
if reply := relayCursorHook(t, s.testEnv, postToolUse("conv-main")); len(reply) != 0 {
|
|
t.Fatalf("withdrawn guidance was delivered later: %#v", reply)
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementEndFailsPendingAndCleansUp(t *testing.T) {
|
|
s, cwd := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
done := supplementAsync(t, s, t.Context(), "too late")
|
|
|
|
s.end()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("pending supplement at turn end = %v, want context.Canceled", err)
|
|
}
|
|
if s.ready() {
|
|
t.Fatal("ready after the turn ended")
|
|
}
|
|
if err := s.supplement(t.Context(), "after"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("supplement after end = %v, want context.Canceled", err)
|
|
}
|
|
|
|
getenv := s.testEnv
|
|
s.close()
|
|
if _, err := os.Stat(filepath.Join(cwd, ".cursor", "hooks.json")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("hooks.json after close: %v, want removed", err)
|
|
}
|
|
if reply := relayCursorHook(t, getenv, postToolUse("conv-main")); len(reply) != 0 {
|
|
t.Fatalf("closed endpoint replied %#v", reply)
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementRejectsOversizedGuidance(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
err := s.supplement(t.Context(), strings.Repeat("界", cursorHookContextLimit))
|
|
if err == nil || !strings.Contains(err.Error(), "limit") {
|
|
t.Fatalf("oversized supplement = %v, want limit error", err)
|
|
}
|
|
if len(s.pending) != 0 {
|
|
t.Fatal("oversized guidance was queued")
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementBatchesInCommentOrder(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
input := func(text string) *cursorSupplementInput {
|
|
return &cursorSupplementInput{ctx: t.Context(), text: text, done: make(chan error, 1)}
|
|
}
|
|
first := input(strings.Repeat("a", 6000))
|
|
second := input(strings.Repeat("b", 6000))
|
|
third := input("c")
|
|
s.pending = []*cursorSupplementInput{first, second, third}
|
|
|
|
req := cursorHookRequest{Event: "postToolUse", ConversationID: "conv-main"}
|
|
output := s.prepareHook(req)
|
|
if len(s.inflight) != 1 || s.inflight[0] != first || output["additional_context"] != first.text {
|
|
t.Fatalf("first hook claimed %d inputs, want only the first", len(s.inflight))
|
|
}
|
|
output = s.prepareHook(req)
|
|
if len(s.inflight) != 3 || s.inflight[1] != second || s.inflight[2] != third ||
|
|
output["additional_context"] != second.text+"\n\n"+third.text {
|
|
t.Fatalf("second hook claimed %d inputs in total, want second then third", len(s.inflight))
|
|
}
|
|
}
|
|
|
|
func TestCursorSupplementKeepsUnmanagedHooksFile(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
path := filepath.Join(cwd, ".cursor", "hooks.json")
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
own := []byte(`{"version":1,"hooks":{"afterFileEdit":[{"command":"./format.sh"}]}}`)
|
|
if err := os.WriteFile(path, own, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := startCursorSupplementSession(t.Context(), cwd, testCursorHookCommand, slog.Default()); err == nil {
|
|
t.Fatal("started over an unmanaged hooks.json")
|
|
}
|
|
if got, _ := os.ReadFile(path); string(got) != string(own) {
|
|
t.Fatalf("unmanaged hooks.json changed to %q", got)
|
|
}
|
|
|
|
stale := []byte(`{"hooks":{"postToolUse":[{"command":"'/old/multica' '` + CursorHookHelperArg + `'"}]}}`)
|
|
if err := os.WriteFile(path, stale, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := startCursorSupplementSession(t.Context(), cwd, testCursorHookCommand, slog.Default())
|
|
if err != nil {
|
|
t.Fatalf("stale managed hooks.json blocked start: %v", err)
|
|
}
|
|
s.close()
|
|
}
|
|
|
|
func TestCursorHookRelayFailsOpen(t *testing.T) {
|
|
s, _ := startTestCursorSupplements(t, t.Context())
|
|
s.activate("conv-main")
|
|
env := func(url, token string) func(string) string {
|
|
return func(name string) string {
|
|
switch name {
|
|
case cursorHookURLEnv:
|
|
return url
|
|
case cursorHookTokenEnv:
|
|
return token
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
for name, getenv := range map[string]func(string) string{
|
|
"no env": env("", ""),
|
|
"wrong token": env(s.url, "nope"),
|
|
"non-loopback": env(strings.Replace(s.url, "127.0.0.1", "192.0.2.1", 1), s.token),
|
|
"unreachable": env("http://127.0.0.1:1/hook", s.token),
|
|
} {
|
|
if reply := relayCursorHook(t, getenv, postToolUse("conv-main")); len(reply) != 0 {
|
|
t.Fatalf("%s: relay replied %#v, want {}", name, reply)
|
|
}
|
|
}
|
|
var out strings.Builder
|
|
RunCursorHookRelay(strings.NewReader("not json"), &out, s.testEnv)
|
|
if out.String() != "{}\n" {
|
|
t.Fatalf("invalid payload: relay printed %q", out.String())
|
|
}
|
|
}
|