Files
tutti/packages/agent/daemon/runtime/standard_acp_interactive_test.go
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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