Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
810 lines
30 KiB
Go
810 lines
30 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func TestStandardACPAdapterSessionStateExposesPendingAskUserPrompt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Hermes Agent", "hermes-session-interactive-1")
|
|
transport.conn.promptKind = "ask-user"
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "hermes-session-interactive-1"
|
|
|
|
var mu sync.Mutex
|
|
var emittedActivity []activityshared.Event
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("choose renderer"), "", "turn-ask-user", func(events []activityshared.Event) {
|
|
mu.Lock()
|
|
emittedActivity = append(emittedActivity, events...)
|
|
mu.Unlock()
|
|
}, nil)
|
|
execDone <- err
|
|
}()
|
|
|
|
waitForCondition(t, func() bool {
|
|
snapshot := adapter.SessionState(session)
|
|
return snapshot.PendingInteractive != nil &&
|
|
snapshot.PendingInteractive.Kind == "ask-user" &&
|
|
snapshot.PendingInteractive.RequestID == "permission-1"
|
|
})
|
|
|
|
snapshot := adapter.SessionState(session)
|
|
if snapshot.PendingInteractive == nil {
|
|
t.Fatal("pending interactive = nil, want ask-user prompt")
|
|
}
|
|
if snapshot.PendingInteractive.ToolName != "AskUserQuestion" {
|
|
t.Fatalf("tool name = %q, want AskUserQuestion", snapshot.PendingInteractive.ToolName)
|
|
}
|
|
questions, _ := snapshot.PendingInteractive.Input["questions"].([]any)
|
|
if len(questions) == 0 {
|
|
t.Fatalf("interactive input = %#v, want questions", snapshot.PendingInteractive.Input)
|
|
}
|
|
mu.Lock()
|
|
events := append([]activityshared.Event(nil), emittedActivity...)
|
|
mu.Unlock()
|
|
if requested := eventsOfType(events, activityshared.EventInteractionRequested); len(requested) != 1 ||
|
|
requested[0].Payload.Interaction == nil || requested[0].Payload.Interaction.Kind != "question" {
|
|
t.Fatalf("ask-user events = %#v, want explicit question interaction.requested", events)
|
|
}
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := adapter.SubmitInteractive(canceled, session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-ask-user",
|
|
RequestID: "permission-1",
|
|
Action: "submit",
|
|
}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled SubmitInteractive error = %v, want context canceled", err)
|
|
}
|
|
if pending := adapter.getPendingApproval(session.AgentSessionID, "turn-ask-user", "permission-1"); pending == nil || pending.disposition() != pendingInteractiveRequestStatePending {
|
|
t.Fatalf("pending disposition after canceled submit = %v, want pending", runtimeInteractiveDisposition(pending))
|
|
}
|
|
|
|
_, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-ask-user",
|
|
RequestID: "permission-1",
|
|
Action: "submit",
|
|
// Canonical GUI ask-user payload: flat display list + keyed map.
|
|
Payload: map[string]any{
|
|
"answers": []any{"Renderer A"},
|
|
"answersByQuestionId": map[string]any{"render-path": "Renderer A"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitInteractive: %v", err)
|
|
}
|
|
if err := <-execDone; err != nil {
|
|
t.Fatalf("Exec after interactive submission: %v", err)
|
|
}
|
|
outcome := transport.conn.interactiveOutcome()
|
|
if got := asString(outcome["outcome"]); got != "submit" {
|
|
t.Fatalf("interactive outcome = %#v, want submit", outcome)
|
|
}
|
|
payload, _ := outcome["payload"].(map[string]any)
|
|
if payload == nil || payload["answersByQuestionId"] == nil {
|
|
t.Fatalf("interactive payload = %#v, want answersByQuestionId", outcome)
|
|
}
|
|
}
|
|
|
|
func TestCursorACPAskQuestionUsesNativeBlockingRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-native-question")
|
|
transport.conn.promptKind = "cursor-ask-question"
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-native-question"
|
|
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("choose a renderer"), "", "turn-cursor-question", func([]activityshared.Event) {}, nil)
|
|
execDone <- err
|
|
}()
|
|
waitForCondition(t, func() bool {
|
|
snapshot := adapter.SessionState(session)
|
|
return snapshot.PendingInteractive != nil && snapshot.PendingInteractive.Kind == "ask-user"
|
|
})
|
|
|
|
snapshot := adapter.SessionState(session)
|
|
if snapshot.PendingInteractive == nil || snapshot.PendingInteractive.ToolName != "AskUserQuestion" {
|
|
t.Fatalf("pending interactive = %#v, want canonical AskUserQuestion", snapshot.PendingInteractive)
|
|
}
|
|
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-cursor-question",
|
|
RequestID: "cursor-ask-1",
|
|
Action: "submit",
|
|
Payload: map[string]any{
|
|
"answersByQuestionId": map[string]any{"renderer": "Modern"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SubmitInteractive: %v", err)
|
|
}
|
|
select {
|
|
case err := <-execDone:
|
|
if err != nil {
|
|
t.Fatalf("Exec after native question response: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
transport.conn.mu.Lock()
|
|
pendingPromptID := string(transport.conn.pendingPermissionCallID)
|
|
selected := clonePayload(transport.conn.selectedInteractiveResult)
|
|
transport.conn.mu.Unlock()
|
|
t.Fatalf("Exec did not finish after native question response; pendingPromptID=%q selected=%#v", pendingPromptID, selected)
|
|
}
|
|
outcome := transport.conn.interactiveOutcome()
|
|
if got := asString(outcome["outcome"]); got != "answered" {
|
|
t.Fatalf("native question outcome = %#v, want answered", outcome)
|
|
}
|
|
answers := payloadArray(outcome["answers"])
|
|
if len(answers) != 1 || asString(payloadObject(answers[0])["questionId"]) != "renderer" {
|
|
t.Fatalf("native question answers = %#v, want renderer answer", outcome)
|
|
}
|
|
selected, ok := payloadObject(answers[0])["selectedOptionIds"].([]any)
|
|
if !ok || len(selected) != 1 || asString(selected[0]) != "modern" {
|
|
t.Fatalf("native question selected ids = %#v, want modern", outcome)
|
|
}
|
|
}
|
|
|
|
func TestCursorACPCreatePlanUsesNativeBlockingRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Cursor Agent", "cursor-session-native-plan")
|
|
transport.conn.promptKind = "cursor-create-plan"
|
|
adapter := newCursorAdapterWithHostMetadata(transport, LegacyHostMetadata(), nil)
|
|
session := standardTestSession(ProviderCursor)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "cursor-session-native-plan"
|
|
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("make a plan"), "", "turn-cursor-plan", func([]activityshared.Event) {}, nil)
|
|
execDone <- err
|
|
}()
|
|
waitForCondition(t, func() bool {
|
|
snapshot := adapter.SessionState(session)
|
|
return snapshot.PendingInteractive != nil && snapshot.PendingInteractive.Kind == "exit-plan"
|
|
})
|
|
|
|
snapshot := adapter.SessionState(session)
|
|
if snapshot.PendingInteractive == nil || snapshot.PendingInteractive.ToolName != "CreatePlan" {
|
|
t.Fatalf("pending interactive = %#v, want native CreatePlan", snapshot.PendingInteractive)
|
|
}
|
|
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-cursor-plan",
|
|
RequestID: "cursor-plan-1",
|
|
Action: "allow",
|
|
OptionID: "accept",
|
|
}); err != nil {
|
|
t.Fatalf("SubmitInteractive: %v", err)
|
|
}
|
|
if err := <-execDone; err != nil {
|
|
t.Fatalf("Exec after native plan response: %v", err)
|
|
}
|
|
outcome := transport.conn.interactiveOutcome()
|
|
if got := asString(outcome["outcome"]); got != "accepted" {
|
|
t.Fatalf("native plan outcome = %#v, want accepted", outcome)
|
|
}
|
|
}
|
|
|
|
// Kimi Code sends session/request_permission before the matching tool_call
|
|
// update. The first frame identifies AskUserQuestion and its selectable
|
|
// outcomes; the next frame carries the question body. AgentGUI reads canonical
|
|
// Interactions rather than transcript tool rows, so the adapter must join both
|
|
// frames before publishing interaction.requested.
|
|
func TestStandardACPAdapterJoinsKimiAskUserQuestionInputAfterPermission(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Kimi Code", "kimi-session-interactive-1")
|
|
transport.conn.promptKind = "ask-user-after-permission"
|
|
toolUpdateGate := make(chan struct{})
|
|
transport.conn.pauseBeforeAskUserToolUpdate = toolUpdateGate
|
|
var releaseToolUpdate sync.Once
|
|
release := func() {
|
|
releaseToolUpdate.Do(func() {
|
|
close(toolUpdateGate)
|
|
})
|
|
}
|
|
defer release()
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
session.Provider = "acp:kimi-code"
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "kimi-session-interactive-1"
|
|
|
|
var mu sync.Mutex
|
|
var emittedActivity []activityshared.Event
|
|
permissionStarted := make(chan struct{}, 1)
|
|
requested := make(chan activityshared.Event, 1)
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("ask how I feel"), "", "turn-kimi-ask-user", func(events []activityshared.Event) {
|
|
mu.Lock()
|
|
emittedActivity = append(emittedActivity, events...)
|
|
mu.Unlock()
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventCallStarted &&
|
|
event.Payload.CallID == "interactive-ask-1" {
|
|
select {
|
|
case permissionStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
if event.Type == activityshared.EventInteractionRequested {
|
|
select {
|
|
case requested <- event:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}, nil)
|
|
execDone <- err
|
|
}()
|
|
|
|
select {
|
|
case <-permissionStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Kimi permission request was not observed before the tool update")
|
|
}
|
|
if snapshot := adapter.SessionState(session); snapshot.PendingInteractive != nil {
|
|
t.Fatalf(
|
|
"pending interactive before tool input = %#v, want incomplete prompt hidden from SessionState",
|
|
snapshot.PendingInteractive,
|
|
)
|
|
}
|
|
mu.Lock()
|
|
prematureRequested := eventsOfType(emittedActivity, activityshared.EventInteractionRequested)
|
|
mu.Unlock()
|
|
if len(prematureRequested) != 0 {
|
|
t.Fatalf("premature interaction.requested events = %#v, want none before tool input", prematureRequested)
|
|
}
|
|
release()
|
|
|
|
var interaction activityshared.Event
|
|
select {
|
|
case interaction = <-requested:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("AskUserQuestion interaction was not published after the tool input arrived")
|
|
}
|
|
if interaction.Payload.Interaction == nil {
|
|
t.Fatalf("interaction event = %#v, want canonical interaction payload", interaction)
|
|
}
|
|
questions := payloadArray(interaction.Payload.Interaction.Input["questions"])
|
|
if len(questions) != 1 ||
|
|
asString(questions[0]["id"]) != "question-1" ||
|
|
asString(questions[0]["question"]) != "你今天心情怎么样?" ||
|
|
len(payloadArray(questions[0]["options"])) != 3 ||
|
|
questions[0]["allowFreeText"] != false {
|
|
t.Fatalf("interaction questions = %#v, want one normalized option-only Kimi question", questions)
|
|
}
|
|
|
|
snapshot := adapter.SessionState(session)
|
|
if snapshot.PendingInteractive == nil ||
|
|
len(payloadArray(snapshot.PendingInteractive.Input["questions"])) != 1 {
|
|
t.Fatalf("pending interactive = %#v, want the joined Kimi question input", snapshot.PendingInteractive)
|
|
}
|
|
|
|
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-kimi-ask-user",
|
|
RequestID: "permission-1",
|
|
Action: "submit",
|
|
Payload: map[string]any{
|
|
"answers": []any{"很好"},
|
|
"answersByQuestionId": map[string]any{"question-1": "很好"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("SubmitInteractive: %v", err)
|
|
}
|
|
select {
|
|
case err := <-execDone:
|
|
if err != nil {
|
|
t.Fatalf("Exec after interactive submission: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec did not finish after the Kimi interactive response")
|
|
}
|
|
|
|
mu.Lock()
|
|
requestedEvents := eventsOfType(emittedActivity, activityshared.EventInteractionRequested)
|
|
mu.Unlock()
|
|
if len(requestedEvents) != 1 {
|
|
t.Fatalf("interaction.requested count = %d, want one complete canonical interaction", len(requestedEvents))
|
|
}
|
|
outcome := transport.conn.interactiveOutcome()
|
|
if got := asString(outcome["outcome"]); got != "selected" {
|
|
t.Fatalf("Kimi ACP outcome = %#v, want selected", outcome)
|
|
}
|
|
if got := asString(outcome["optionId"]); got != "q0_opt_0" {
|
|
t.Fatalf("Kimi ACP outcome = %#v, want optionId q0_opt_0", outcome)
|
|
}
|
|
if payload, _ := outcome["payload"].(map[string]any); len(payload) != 0 {
|
|
t.Fatalf("Kimi ACP outcome = %#v, want protocol-native selected response without payload", outcome)
|
|
}
|
|
callCompletedIndex := -1
|
|
providerCompletedIndex := -1
|
|
var callCompletedOutput map[string]any
|
|
for index, event := range emittedActivity {
|
|
if event.Type == activityshared.EventCallCompleted &&
|
|
event.Payload.CallID == "interactive-ask-1" {
|
|
callCompletedIndex = index
|
|
callCompletedOutput = event.Payload.Output
|
|
}
|
|
if event.Type == activityshared.EventRootProviderTurnCompleted {
|
|
providerCompletedIndex = index
|
|
}
|
|
}
|
|
if callCompletedIndex < 0 ||
|
|
providerCompletedIndex < 0 ||
|
|
callCompletedIndex >= providerCompletedIndex {
|
|
t.Fatalf(
|
|
"interactive call completed index=%d provider turn completed index=%d, want local resolution first",
|
|
callCompletedIndex,
|
|
providerCompletedIndex,
|
|
)
|
|
}
|
|
localPayload := payloadObject(callCompletedOutput["payload"])
|
|
localAnswers, _ := localPayload["answers"].([]any)
|
|
if len(localAnswers) != 1 || asString(localAnswers[0]) != "很好" {
|
|
t.Fatalf("local completed output = %#v, want the canonical answer preserved", callCompletedOutput)
|
|
}
|
|
localAnswersByQuestionID := payloadObject(localPayload["answersByQuestionId"])
|
|
if len(localAnswersByQuestionID) != 1 || asString(localAnswersByQuestionID["question-1"]) != "很好" {
|
|
t.Fatalf("local completed output = %#v, want the canonical per-question answer preserved", callCompletedOutput)
|
|
}
|
|
}
|
|
|
|
func TestStandardACPAdapterJoinsKimiApprovalDetailAfterPermission(t *testing.T) {
|
|
t.Parallel()
|
|
transport := newStandardACPTransport("Kimi Code", "kimi-session-approval-1")
|
|
transport.conn.promptKind = "approval-after-permission"
|
|
gate := make(chan struct{})
|
|
transport.conn.pauseBeforeAskUserToolUpdate = gate
|
|
adapter := newKimiCodeExtensionTestAdapter(t, transport)
|
|
session := standardTestSession("acp:kimi-code")
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.ProviderSessionID = "kimi-session-approval-1"
|
|
requested := make(chan activityshared.Event, 2)
|
|
started := make(chan struct{}, 1)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("read the file"), "", "turn-kimi-read", func(events []activityshared.Event) {
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventCallStarted && event.Payload.CallID == "read-file-1" {
|
|
select {
|
|
case started <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
if event.Type == activityshared.EventInteractionRequested {
|
|
requested <- event
|
|
}
|
|
}
|
|
}, nil)
|
|
done <- err
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("permission not observed")
|
|
}
|
|
select {
|
|
case event := <-requested:
|
|
t.Fatalf("premature interaction: %#v", event)
|
|
default:
|
|
}
|
|
wrongTurnUpdate := json.RawMessage(`{"update":{"sessionUpdate":"tool_call","toolCallId":"read-file-1","title":"Read file","kind":"read","status":"pending","rawInput":{"path":"C:\\\\Users\\\\anonymous\\\\workspace\\\\wrong-turn.txt"}}}`)
|
|
wrongTurnNormalizer := newACPTurnNormalizer()
|
|
_ = standardACPUpdateEvents(adapter.config, session, "turn-other", wrongTurnUpdate, wrongTurnNormalizer)
|
|
if events := adapter.standardACPDeferredInteractionRequestedEvents(session, "turn-other", wrongTurnUpdate, wrongTurnNormalizer); len(events) != 0 {
|
|
t.Fatalf("wrong-turn update published interaction: %#v", events)
|
|
}
|
|
close(gate)
|
|
var event activityshared.Event
|
|
select {
|
|
case event = <-requested:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("joined interaction not published")
|
|
}
|
|
toolInput := payloadObject(payloadObject(event.Payload.Interaction.Input["toolCall"])["input"])
|
|
if got := asString(toolInput["path"]); got != `C:\Users\anonymous\workspace\secret.txt` {
|
|
t.Fatalf("path=%q input=%#v", got, event.Payload.Interaction.Input)
|
|
}
|
|
select {
|
|
case duplicate := <-requested:
|
|
t.Fatalf("duplicate interaction: %#v", duplicate)
|
|
default:
|
|
}
|
|
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{TurnID: "turn-kimi-read", RequestID: "permission-1", OptionID: "reject"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("exec did not finish")
|
|
}
|
|
select {
|
|
case stale := <-requested:
|
|
t.Fatalf("stale interaction after resolution=%#v", stale)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestStandardACPAdapterKeepsKimiApprovalHiddenWhenToolUpdateNeverArrives(t *testing.T) {
|
|
t.Parallel()
|
|
transport := newStandardACPTransport("Kimi Code", "kimi-session-fallback")
|
|
transport.conn.promptPermission = true
|
|
adapter := newKimiCodeExtensionTestAdapter(t, transport)
|
|
session := standardTestSession("acp:kimi-code")
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.ProviderSessionID = "kimi-session-fallback"
|
|
permissionStarted := make(chan struct{}, 1)
|
|
cancellationResolved := make(chan struct{}, 1)
|
|
done := make(chan error, 1)
|
|
var mu sync.Mutex
|
|
var emittedActivity []activityshared.Event
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("run"), "", "turn-fallback", func(events []activityshared.Event) {
|
|
batchStarted := false
|
|
batchResolved := false
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventCallStarted && event.Payload.CallID == "approval-1" {
|
|
batchStarted = true
|
|
}
|
|
if event.Type == activityshared.EventCallFailed && event.Payload.CallID == "approval-1" {
|
|
batchResolved = true
|
|
}
|
|
}
|
|
mu.Lock()
|
|
emittedActivity = append(emittedActivity, events...)
|
|
mu.Unlock()
|
|
if batchStarted {
|
|
select {
|
|
case permissionStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
if batchResolved {
|
|
select {
|
|
case cancellationResolved <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}, nil)
|
|
done <- err
|
|
}()
|
|
select {
|
|
case <-permissionStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("permission not observed")
|
|
}
|
|
mu.Lock()
|
|
requestedBeforeCancel := eventsOfType(emittedActivity, activityshared.EventInteractionRequested)
|
|
supersededBeforeCancel := eventsOfType(emittedActivity, activityshared.EventInteractionSuperseded)
|
|
mu.Unlock()
|
|
if len(requestedBeforeCancel) != 0 || len(supersededBeforeCancel) != 0 {
|
|
t.Fatalf("interaction events before cancellation: requested=%#v superseded=%#v", requestedBeforeCancel, supersededBeforeCancel)
|
|
}
|
|
if snapshot := adapter.SessionState(session); snapshot.PendingInteractive != nil {
|
|
t.Fatalf("pending interactive before cancellation=%#v, want hidden approval", snapshot.PendingInteractive)
|
|
}
|
|
cancelEvents, err := adapter.Cancel(context.Background(), session, "user")
|
|
if err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if len(cancelEvents) != 0 {
|
|
t.Fatalf("Cancel events=%#v, want provider acknowledgment events only", cancelEvents)
|
|
}
|
|
transport.conn.mu.Lock()
|
|
cancelCalls := transport.conn.cancelCalls
|
|
cancelSessionID := asString(transport.conn.lastCancelParams["sessionId"])
|
|
transport.conn.mu.Unlock()
|
|
if cancelCalls != 1 || cancelSessionID != session.ProviderSessionID {
|
|
t.Fatalf("ACP cancel calls=%d sessionId=%q, want one call for %q", cancelCalls, cancelSessionID, session.ProviderSessionID)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Exec error=%v, want nil or canceled", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("exec did not finish")
|
|
}
|
|
select {
|
|
case <-cancellationResolved:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("permission cancellation did not resolve")
|
|
}
|
|
mu.Lock()
|
|
requested := eventsOfType(emittedActivity, activityshared.EventInteractionRequested)
|
|
superseded := eventsOfType(emittedActivity, activityshared.EventInteractionSuperseded)
|
|
failed := eventsOfType(emittedActivity, activityshared.EventCallFailed)
|
|
turnUpdates := eventsOfType(emittedActivity, activityshared.EventTurnUpdated)
|
|
mu.Unlock()
|
|
if len(requested) != 0 || len(superseded) != 0 {
|
|
t.Fatalf("hidden approval interaction events after cancellation: requested=%#v superseded=%#v", requested, superseded)
|
|
}
|
|
if snapshot := adapter.SessionState(session); snapshot.PendingInteractive != nil {
|
|
t.Fatalf("pending interactive after cancellation=%#v, want nil", snapshot.PendingInteractive)
|
|
}
|
|
if pending := adapter.getPendingApproval(session.AgentSessionID, "turn-fallback", "permission-1"); pending != nil {
|
|
t.Fatalf("pending approval after cancellation=%#v, want removed", pending)
|
|
}
|
|
if got := adapter.terminalInteractiveDisposition(session.AgentSessionID, "turn-fallback", "permission-1"); got != InteractiveDispositionInterrupted {
|
|
t.Fatalf("terminal disposition=%q, want %q", got, InteractiveDispositionInterrupted)
|
|
}
|
|
if len(failed) != 1 || failed[0].Payload.CallID != "approval-1" {
|
|
t.Fatalf("call.failed events=%#v, want approval-1 failure", failed)
|
|
}
|
|
foundWorking := false
|
|
for _, event := range turnUpdates {
|
|
if event.Payload.TurnID == "turn-fallback" && event.Payload.TurnPhase == string(activityshared.TurnPhaseWorking) {
|
|
foundWorking = true
|
|
}
|
|
}
|
|
if !foundWorking {
|
|
t.Fatalf("turn.updated events=%#v, want turn-fallback working", turnUpdates)
|
|
}
|
|
}
|
|
|
|
func TestStandardACPAdapterRejectsUnsupportedKimiAskUserQuestionBeforePublication(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
promptKind string
|
|
wantError string
|
|
}{
|
|
{
|
|
name: "multiple questions",
|
|
promptKind: "ask-user-after-permission-multi-question",
|
|
wantError: "exactly one question",
|
|
},
|
|
{
|
|
name: "multi select",
|
|
promptKind: "ask-user-after-permission-multi-select",
|
|
wantError: "does not support multi-select",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Kimi Code", "kimi-session-unsupported-"+strings.ReplaceAll(tt.name, " ", "-"))
|
|
transport.conn.promptKind = tt.promptKind
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
session.Provider = "acp:kimi-code"
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = transport.conn.sessionID
|
|
|
|
var mu sync.Mutex
|
|
var emittedActivity []activityshared.Event
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("ask unsupported question"), "", "turn-kimi-unsupported", func(events []activityshared.Event) {
|
|
mu.Lock()
|
|
emittedActivity = append(emittedActivity, events...)
|
|
mu.Unlock()
|
|
}, nil)
|
|
execDone <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-execDone:
|
|
if err != nil {
|
|
t.Fatalf("Exec after unsupported question rejection: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec did not finish after rejecting the unsupported Kimi question")
|
|
}
|
|
|
|
mu.Lock()
|
|
requested := eventsOfType(emittedActivity, activityshared.EventInteractionRequested)
|
|
superseded := eventsOfType(emittedActivity, activityshared.EventInteractionSuperseded)
|
|
completed := eventsOfType(emittedActivity, activityshared.EventCallCompleted)
|
|
failed := eventsOfType(emittedActivity, activityshared.EventCallFailed)
|
|
mu.Unlock()
|
|
if len(requested) != 0 {
|
|
t.Fatalf("interaction.requested events = %#v, want none for unsupported provider shape", requested)
|
|
}
|
|
if len(superseded) != 0 {
|
|
t.Fatalf("interaction.superseded events = %#v, want no canonical Interaction before publication", superseded)
|
|
}
|
|
for _, event := range completed {
|
|
if event.Payload.CallID == "interactive-ask-1" {
|
|
t.Fatalf("unsupported AskUserQuestion completed locally: %#v", event)
|
|
}
|
|
}
|
|
if len(failed) == 0 {
|
|
t.Fatal("unsupported AskUserQuestion did not emit call.failed")
|
|
}
|
|
responseErr := transport.conn.interactiveError()
|
|
if responseErr == nil || !strings.Contains(responseErr.Message, tt.wantError) {
|
|
t.Fatalf("provider response error = %#v, want containing %q", responseErr, tt.wantError)
|
|
}
|
|
if snapshot := adapter.SessionState(session); snapshot.PendingInteractive != nil {
|
|
t.Fatalf("pending interactive = %#v, want unsupported request removed", snapshot.PendingInteractive)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStandardACPAdapterRejectsNonCanonicalKimiAskUserAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Kimi Code", "kimi-session-invalid-answer")
|
|
transport.conn.promptKind = "ask-user-after-permission"
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
session.Provider = "acp:kimi-code"
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "kimi-session-invalid-answer"
|
|
|
|
var mu sync.Mutex
|
|
var emittedActivity []activityshared.Event
|
|
requested := make(chan struct{}, 1)
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("ask how I feel"), "", "turn-kimi-invalid-answer", func(events []activityshared.Event) {
|
|
mu.Lock()
|
|
emittedActivity = append(emittedActivity, events...)
|
|
mu.Unlock()
|
|
for _, event := range events {
|
|
if event.Type == activityshared.EventInteractionRequested {
|
|
select {
|
|
case requested <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}, nil)
|
|
execDone <- err
|
|
}()
|
|
|
|
select {
|
|
case <-requested:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("supported Kimi question was not published")
|
|
}
|
|
result, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-kimi-invalid-answer",
|
|
RequestID: "permission-1",
|
|
Action: "submit",
|
|
Payload: map[string]any{
|
|
"answers": []any{"很好", "一般"},
|
|
"answersByQuestionId": map[string]any{
|
|
"question-1": "很好",
|
|
"question-2": "一般",
|
|
},
|
|
},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "exactly one canonical answer") {
|
|
t.Fatalf("SubmitInteractive error = %v, want canonical-answer rejection", err)
|
|
}
|
|
if result.Disposition != InteractiveDispositionSuperseded {
|
|
t.Fatalf("SubmitInteractive disposition = %q, want superseded", result.Disposition)
|
|
}
|
|
select {
|
|
case err := <-execDone:
|
|
if err != nil {
|
|
t.Fatalf("Exec after invalid answer rejection: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Exec did not finish after invalid answer rejection")
|
|
}
|
|
|
|
mu.Lock()
|
|
completed := eventsOfType(emittedActivity, activityshared.EventCallCompleted)
|
|
failed := eventsOfType(emittedActivity, activityshared.EventCallFailed)
|
|
superseded := eventsOfType(emittedActivity, activityshared.EventInteractionSuperseded)
|
|
mu.Unlock()
|
|
for _, event := range completed {
|
|
if event.Payload.CallID == "interactive-ask-1" {
|
|
t.Fatalf("invalid AskUser answer completed locally: %#v", event)
|
|
}
|
|
}
|
|
if len(failed) == 0 || len(superseded) == 0 {
|
|
t.Fatalf("failed events = %d, superseded events = %d, want both", len(failed), len(superseded))
|
|
}
|
|
if responseErr := transport.conn.interactiveError(); responseErr == nil ||
|
|
!strings.Contains(responseErr.Message, "exactly one canonical answer") {
|
|
t.Fatalf("provider response error = %#v, want canonical-answer rejection", responseErr)
|
|
}
|
|
if disposition := adapter.InteractiveDisposition(session, "turn-kimi-invalid-answer", "permission-1"); disposition != InteractiveDispositionSuperseded {
|
|
t.Fatalf("terminal disposition = %q, want superseded", disposition)
|
|
}
|
|
}
|
|
|
|
func TestStandardACPAdapterSessionStateExposesPendingExitPlanPrompt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newStandardACPTransport("Hermes Agent", "hermes-session-plan-1")
|
|
transport.conn.promptKind = "exit-plan"
|
|
adapter := newHermesExtensionTestAdapter(transport)
|
|
session := standardTestSession(hermesExtensionTestProvider)
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session.ProviderSessionID = "hermes-session-plan-1"
|
|
|
|
execDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := adapter.Exec(context.Background(), session, textPrompt("review plan"), "", "turn-plan", func([]activityshared.Event) {}, nil)
|
|
execDone <- err
|
|
}()
|
|
|
|
waitForCondition(t, func() bool {
|
|
snapshot := adapter.SessionState(session)
|
|
return snapshot.PendingInteractive != nil &&
|
|
snapshot.PendingInteractive.Kind == "exit-plan" &&
|
|
snapshot.PendingInteractive.RequestID == "permission-1"
|
|
})
|
|
|
|
snapshot := adapter.SessionState(session)
|
|
if snapshot.PendingInteractive == nil || snapshot.PendingInteractive.ToolName != "ExitPlanMode" {
|
|
t.Fatalf("pending interactive = %#v, want ExitPlanMode", snapshot.PendingInteractive)
|
|
}
|
|
|
|
_, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
TurnID: "turn-plan",
|
|
RequestID: "permission-1",
|
|
Action: "allow",
|
|
OptionID: "acceptEdits",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitInteractive: %v", err)
|
|
}
|
|
if err := <-execDone; err != nil {
|
|
t.Fatalf("Exec after exit-plan submission: %v", err)
|
|
}
|
|
outcome := transport.conn.interactiveOutcome()
|
|
if got := asString(outcome["optionId"]); got != "acceptEdits" {
|
|
t.Fatalf("interactive outcome = %#v, want optionId acceptEdits", outcome)
|
|
}
|
|
}
|