Files
tutti/packages/agent/daemon/runtime/standard_acp_events_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

524 lines
20 KiB
Go

package agentruntime
import (
"encoding/json"
"strings"
"testing"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func TestStandardACPToolCallEventInfersCompletedStatusFromRawOutput(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
completed, ok := standardACPToolCallEventWithID(session, "event-complete-inferred", "turn-1", "tool_call_update", readSessionTestdataJSON(t, "standard_acp_tool_call_update_completed_without_status.json"))
if !ok {
t.Fatal("standardACPToolCallEventWithID(inferred complete) returned !ok")
}
if completed.Type != activityshared.EventCallCompleted {
t.Fatalf("completed event type = %s, want call.completed", completed.Type)
}
if completed.Payload.Output["stdout"] != "/workspace/app\n" {
t.Fatalf("completed output = %#v, want stdout preserved", completed.Payload.Output)
}
if completed.Payload.Metadata["toolName"] != "Bash" {
t.Fatalf("completed tool name = %#v, want Bash", completed.Payload.Metadata["toolName"])
}
}
func TestStandardACPToolAliasOverridesProviderToolIDDeclaratively(t *testing.T) {
update := map[string]any{"title": "replace", "toolCallId": "call-1"}
applyStandardACPToolAlias(standardACPConfig{toolAliases: map[string]string{"replace": "Edit"}}, update)
if got := update["toolName"]; got != "Edit" {
t.Fatalf("toolName = %#v, want Edit", got)
}
}
func TestStandardACPToolCallEventInfersFailedStatusFromRawOutput(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
failed, ok := standardACPToolCallEventWithID(session, "event-failed-inferred", "turn-1", "tool_call_update", readSessionTestdataJSON(t, "standard_acp_tool_call_update_failed_without_status.json"))
if !ok {
t.Fatal("standardACPToolCallEventWithID(inferred failed) returned !ok")
}
if failed.Type != activityshared.EventCallFailed {
t.Fatalf("failed event type = %s, want call.failed", failed.Type)
}
if failed.Payload.Error["output"] != "Exit code 137" {
t.Fatalf("failed error = %#v, want raw output preserved", failed.Payload.Error)
}
}
func TestStandardACPToolCallEventPreservesCursorTransportFailureDetails(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderCursor)
failed, ok := standardACPToolCallEventWithID(session, "event-cursor-read-failed", "turn-1", "tool_call_update", map[string]any{
"sessionUpdate": "tool_call_update",
"toolCallId": "read-1",
"title": "Read file",
"status": "completed",
"kind": "read",
"rawOutput": map[string]any{
"result": "Error: Aborted",
"rawErrorMessages": []any{"[aborted] Client network socket disconnected before secure TLS connection was established"},
},
})
if !ok {
t.Fatal("standardACPToolCallEventWithID(cursor read failure) returned !ok")
}
if failed.Type != activityshared.EventCallFailed {
t.Fatalf("failed event type = %s, want call.failed", failed.Type)
}
if got := activityshared.BestEffortErrorMessage(failed.Payload); !strings.Contains(got, "Client network socket disconnected") {
t.Fatalf("best-effort error = %q, want Cursor transport detail", got)
}
if got := asString(failed.Payload.Error["error"]); !strings.Contains(got, "Error: Aborted") || !strings.Contains(got, "Client network socket disconnected") {
t.Fatalf("normalized error = %#v, want result and raw transport details", failed.Payload.Error["error"])
}
if got := asString(failed.Payload.Output["rawErrorMessages"]); got != "" {
t.Fatalf("mirrored rawErrorMessages unexpectedly stringified = %q", got)
}
if _, ok := failed.Payload.Output["rawErrorMessages"]; !ok {
t.Fatalf("mirrored output = %#v, want rawErrorMessages preserved", failed.Payload.Output)
}
}
func TestStandardACPNonzeroToolExitDoesNotFailCompletedAssistantTurn(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
normalizer := newACPTurnNormalizer()
toolEvents := standardACPUpdateEvents(standardACPConfig{provider: hermesExtensionTestProvider}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "git-check-1",
"title": "Run command",
"kind": "execute",
"rawInput": {"command": "powershell.exe -Command 'git status; git diff; git diff --no-index -- /dev/null README.md'"},
"rawOutput": {"exitCode": 1, "stdout": "diff --git a/README.md b/README.md"}
}
}`), normalizer)
if len(toolEvents) != 1 || toolEvents[0].Type != activityshared.EventCallFailed {
t.Fatalf("tool events = %#v, want one call.failed", toolEvents)
}
normalizer.ApplyAssistantFinalText("The comparison completed and found differences.")
turnEvents := normalizer.FinishCompleted(session, "turn-1")
assistant := activityMessagesWithRole(turnEvents, activityshared.MessageRoleAssistant)
if len(assistant) != 1 || assistant[0].Payload.Metadata["streamState"] != messageStreamStateCompleted {
t.Fatalf("assistant events = %#v, want completed assistant response", assistant)
}
}
func TestStandardACPNormalizerKeepsCanonicalToolIdentityAcrossDynamicTitles(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderOpenCode)
normalizer := newACPTurnNormalizer()
started := standardACPUpdateEvents(standardACPConfig{provider: ProviderOpenCode}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "edit-1",
"title": "apply_patch",
"status": "in_progress",
"kind": "edit",
"rawInput": {"patchText": "*** Begin Patch"}
}
}`), normalizer)
completed := standardACPUpdateEvents(standardACPConfig{provider: ProviderOpenCode}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "edit-1",
"title": "Success. Updated the following files: index.html",
"status": "completed",
"kind": "edit",
"rawInput": {"patchText": "*** Begin Patch"},
"rawOutput": {"metadata": {"diff": "Index: index.html", "files": [{"filePath": "index.html"}]}}
}
}`), normalizer)
if len(started) != 1 || started[0].Payload.Metadata["toolName"] != "Edit" {
t.Fatalf("started events = %#v, want canonical Edit", started)
}
if len(completed) != 1 || completed[0].Payload.Metadata["toolName"] != "Edit" {
t.Fatalf("completed events = %#v, want stable canonical Edit", completed)
}
if completed[0].Payload.Metadata["kind"] != "edit" {
t.Fatalf("completed metadata = %#v, want ACP kind", completed[0].Payload.Metadata)
}
if completed[0].Payload.Output["diff"] != "Index: index.html" {
t.Fatalf("completed output = %#v, want promoted diff", completed[0].Payload.Output)
}
if files, ok := completed[0].Payload.Output["files"].([]any); !ok || len(files) != 1 {
t.Fatalf("completed output = %#v, want promoted files", completed[0].Payload.Output)
}
}
func TestCursorACPDeleteToolPreservesDeletedFileChangeKind(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderCursor)
normalizer := newACPTurnNormalizer()
events := standardACPUpdateEvents(standardACPConfig{provider: ProviderCursor}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "delete-1",
"title": "Delete obsolete.txt",
"status": "completed",
"kind": "delete",
"content": [{
"type": "diff",
"path": "/workspace/obsolete.txt",
"oldText": "obsolete\n",
"newText": ""
}]
}
}`), normalizer)
if len(events) != 2 || events[0].Type != activityshared.EventCallCompleted || events[1].Type != activityshared.EventTurnUpdated {
t.Fatalf("delete events = %#v, want completed call followed by turn.updated", events)
}
if events[0].Payload.Metadata["toolName"] != "Write" {
t.Fatalf("delete tool name = %#v, want Cursor-compatible Write", events[0].Payload.Metadata["toolName"])
}
files := payloadArray(payloadMap(events[1].Payload.Metadata, "fileChanges")["files"])
if len(files) != 1 || files[0]["change"] != "deleted" {
t.Fatalf("turn file changes = %#v, want one deleted file", files)
}
}
func TestACPToolNameRecognizesOpenCodeTodoPayload(t *testing.T) {
t.Parallel()
if got := acpToolName("todo-1", "0 todos", "other", map[string]any{"todos": []any{}}); got != "TodoWrite" {
t.Fatalf("acpToolName() = %q, want TodoWrite", got)
}
}
func TestStandardACPToolCallLifecycleReusesStableEventID(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
provider string
config standardACPConfig
}{
{
name: "hermes default config",
provider: hermesExtensionTestProvider,
config: standardACPConfig{provider: hermesExtensionTestProvider},
},
{
name: "hermes",
provider: hermesExtensionTestProvider,
config: standardACPConfig{provider: hermesExtensionTestProvider},
},
{
name: "openclaw",
provider: ProviderOpenClaw,
config: standardACPConfig{provider: ProviderOpenClaw},
},
{
name: "opencode",
provider: ProviderOpenCode,
config: standardACPConfig{provider: ProviderOpenCode},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
session := standardTestSession(tc.provider)
session.ProviderSessionID = tc.name + "-session-1"
normalizer := newACPTurnNormalizer()
started := standardACPUpdateEvents(tc.config, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tool-current",
"title": "Bash",
"status": "pending",
"kind": "tool",
"rawInput": {"command": "pwd"}
}
}`), normalizer)
if len(started) != 1 || started[0].Type != activityshared.EventCallStarted {
t.Fatalf("started events = %#v, want one call.started", started)
}
completed := standardACPUpdateEvents(tc.config, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "tool-current",
"title": "Bash",
"status": "completed",
"kind": "tool",
"rawOutput": {"stdout": "/workspace/app\n"}
}
}`), normalizer)
if len(completed) != 1 || completed[0].Type != activityshared.EventCallCompleted {
t.Fatalf("completed events = %#v, want one call.completed", completed)
}
if started[0].EventID == "" {
t.Fatalf("started event id = empty, want stable event id")
}
if completed[0].EventID != started[0].EventID {
t.Fatalf("event ids = %q / %q, want same stable tool event id", started[0].EventID, completed[0].EventID)
}
})
}
}
func TestStandardACPNormalizerSegmentsAssistantAndThinkingAroundToolCalls(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
session.ProviderSessionID = "hermes-session-segment-1"
normalizer := newACPTurnNormalizer()
var events []activityshared.Event
events = append(events, normalizer.AppendThinkingChunk(session, "turn-1", "Thinking before tool. ")...)
events = append(events, normalizer.AppendAssistantChunk(session, "turn-1", "Before tool. ")...)
events = append(events, standardACPUpdateEvents(standardACPConfig{provider: hermesExtensionTestProvider}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tool-segment-1",
"title": "Bash",
"status": "pending"
}
}`), normalizer)...)
events = append(events, normalizer.AppendThinkingChunk(session, "turn-1", "Thinking after tool. ")...)
events = append(events, normalizer.AppendAssistantChunk(session, "turn-1", "After tool.")...)
events = append(events, normalizer.Finish(session, "turn-1", messageStreamStateCompleted)...)
assistantMessages := activityMessagesWithRole(events, activityshared.MessageRoleAssistant)
if len(assistantMessages) != 4 {
t.Fatalf("assistant messages = %#v, want streaming+completed before tool and streaming+completed after tool", assistantMessages)
}
if assistantMessages[0].EventID == "" ||
assistantMessages[1].EventID != assistantMessages[0].EventID ||
assistantMessages[2].EventID == "" ||
assistantMessages[3].EventID != assistantMessages[2].EventID ||
assistantMessages[2].EventID == assistantMessages[0].EventID {
t.Fatalf("assistant event IDs = %#v, want distinct IDs split by tool boundary", assistantMessages)
}
if assistantMessages[1].Payload.Content != "Before tool. " ||
assistantMessages[3].Payload.Content != "After tool." {
t.Fatalf("assistant contents = %#v, want text split around tool call", assistantMessages)
}
thinkingMessages := activityMessagesWithRole(events, activityshared.MessageRoleAssistantThinking)
if len(thinkingMessages) != 4 {
t.Fatalf("thinking messages = %#v, want streaming+completed before tool and streaming+completed after tool", thinkingMessages)
}
if thinkingMessages[0].EventID == "" ||
thinkingMessages[1].EventID != thinkingMessages[0].EventID ||
thinkingMessages[2].EventID == "" ||
thinkingMessages[3].EventID != thinkingMessages[2].EventID ||
thinkingMessages[2].EventID == thinkingMessages[0].EventID {
t.Fatalf("thinking event IDs = %#v, want distinct IDs split by tool boundary", thinkingMessages)
}
if thinkingMessages[1].Payload.Content != "Thinking before tool. " ||
thinkingMessages[3].Payload.Content != "Thinking after tool. " {
t.Fatalf("thinking contents = %#v, want thinking split around tool call", thinkingMessages)
}
if events[2].Type != activityshared.EventMessageAppended ||
events[3].Type != activityshared.EventMessageAppended ||
events[4].Type != activityshared.EventCallStarted ||
events[5].Type != activityshared.EventMessageAppended ||
events[6].Type != activityshared.EventMessageAppended {
t.Fatalf("event order = %#v, want thinking completion, assistant completion, tool call, then next segments", events)
}
}
func TestStandardACPUpdateDoesNotProjectInternalMentionRoutingTitle(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
session.ProviderSessionID = "hermes-session-1"
events := standardACPUpdateEvents(standardACPConfig{provider: hermesExtensionTestProvider}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "session_info_update",
"title": "`+tuttiMentionRoutingReminder+`"
}
}`), newACPTurnNormalizer())
for _, event := range events {
if event.Payload.Title == tuttiMentionRoutingReminder {
t.Fatalf("events = %#v, want internal mention routing title excluded from title updates", events)
}
}
}
func TestStandardACPSystemNoticeChunkProjectsAssistantNotice(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
session.ProviderSessionID = "hermes-session-1"
events := standardACPUpdateEvents(standardACPConfig{provider: hermesExtensionTestProvider}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Codex switched to HTTPS transport."
},
"_meta": {
"tsh": {
"kind": "agent_system_notice",
"noticeKind": "transport_fallback",
"severity": "warning",
"title": "Codex switched to HTTPS transport.",
"detail": "Falling back from WebSockets to HTTPS transport."
}
}
}
}`), newACPTurnNormalizer())
if len(events) != 1 {
t.Fatalf("events = %#v, want one system notice message", events)
}
event := events[0]
if event.Type != activityshared.EventMessageAppended || event.Payload.Role != activityshared.MessageRoleAssistant {
t.Fatalf("event = %#v, want assistant message", event)
}
if got := event.Payload.Metadata["kind"]; got != "agent_system_notice" {
t.Fatalf("notice kind marker = %#v, want agent_system_notice", got)
}
if got := event.Payload.Metadata["noticeKind"]; got != "transport_fallback" {
t.Fatalf("noticeKind = %#v, want transport_fallback", got)
}
}
func TestStandardACPTransportFallbackTextProjectsAssistantNotice(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderNexight)
session.ProviderSessionID = "nexight-session-1"
events := standardACPUpdateEvents(NewNexightAdapter(nil).config, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Falling back from WebSockets to HTTPS transport."
}
}
}`), newACPTurnNormalizer())
if len(events) != 1 {
t.Fatalf("events = %#v, want one system notice message", events)
}
if got := events[0].Payload.Metadata["kind"]; got != "agent_system_notice" {
t.Fatalf("notice kind marker = %#v, want agent_system_notice", got)
}
if got := events[0].Payload.Metadata["noticeKind"]; got != "transport_fallback" {
t.Fatalf("noticeKind = %#v, want transport_fallback", got)
}
}
func TestStandardACPReconnectThoughtChunkProjectsAssistantNotice(t *testing.T) {
t.Parallel()
session := standardTestSession(ProviderNexight)
session.ProviderSessionID = "nexight-session-1"
events := standardACPUpdateEvents(NewNexightAdapter(nil).config, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": {
"type": "text",
"text": "Reconnecting... 1/5 Some(ResponseStreamDisconnected { http_status_code: None })"
}
}
}`), newACPTurnNormalizer())
if len(events) != 1 {
t.Fatalf("events = %#v, want one system notice message", events)
}
if got := events[0].Payload.Metadata["kind"]; got != "agent_system_notice" {
t.Fatalf("notice kind marker = %#v, want agent_system_notice", got)
}
if got := events[0].Payload.Metadata["noticeKind"]; got != "transport_retry" {
t.Fatalf("noticeKind = %#v, want transport_retry", got)
}
}
func TestNexightACPSystemNoticeMessageFromStderr(t *testing.T) {
t.Parallel()
mapper := NewNexightAdapter(nil).config.stderrMessageMapper
if mapper == nil {
t.Fatal("nexight config stderrMessageMapper = nil, want stream-error mapper")
}
message, ok := mapper([]byte(
`2026-05-29T09:05:51.179821Z ERROR codex_acp::thread: Handled error during turn: Reconnecting... 1/5 Some(ResponseStreamDisconnected { http_status_code: Some(401) }) Some("unexpected status 401 Unauthorized")`,
))
if !ok {
t.Fatal("stderr notice ok = false, want true")
}
if message.Method != acpMethodUpdate {
t.Fatalf("method = %q, want %q", message.Method, acpMethodUpdate)
}
var params struct {
Update map[string]any `json:"update"`
}
if err := json.Unmarshal(message.Params, &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if got := params.Update["sessionUpdate"]; got != "stream_error" {
t.Fatalf("sessionUpdate = %#v, want stream_error", got)
}
if got := params.Update["noticeKind"]; got != "transport_retry" {
t.Fatalf("noticeKind = %#v, want transport_retry", got)
}
if got := params.Update["source"]; got != "acp_stderr" {
t.Fatalf("source = %#v, want acp_stderr", got)
}
if _, ok := mapper([]byte("WARN unrelated")); ok {
t.Fatal("generic stderr ok = true, want false")
}
}
func TestStandardACPTransportFallbackTextStaysProviderScoped(t *testing.T) {
t.Parallel()
session := standardTestSession(hermesExtensionTestProvider)
session.ProviderSessionID = "hermes-session-1"
events := standardACPUpdateEvents(standardACPConfig{provider: hermesExtensionTestProvider}, session, "turn-1", json.RawMessage(`{
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Falling back from WebSockets to HTTPS transport."
}
}
}`), newACPTurnNormalizer())
if len(events) != 1 {
t.Fatalf("events = %#v, want normal assistant chunk for non-Codex providers", events)
}
if got := events[0].Payload.Metadata["kind"]; got == "agent_system_notice" {
t.Fatalf("notice kind marker = %#v, want ordinary assistant message", got)
}
if got := events[0].Payload.Content; got != "Falling back from WebSockets to HTTPS transport." {
t.Fatalf("content = %q, want ordinary assistant content", got)
}
}
func firstUserMessageContent(t *testing.T, events []activityshared.Event) string {
t.Helper()
for _, event := range events {
if event.Type == activityshared.EventMessageAppended && event.Payload.Role == activityshared.MessageRoleUser {
return event.Payload.Content
}
}
t.Fatalf("events = %#v, want user message event", events)
return ""
}