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

707 lines
21 KiB
Go

package agentruntime
import (
"encoding/json"
"strings"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func cloneOptionMaps(in []map[string]any) []map[string]any {
if len(in) == 0 {
return nil
}
out := make([]map[string]any, 0, len(in))
for _, item := range in {
out = append(out, clonePayload(item))
}
return out
}
func (p *pendingInteractiveRequest) hasOption(optionID string) bool {
optionID = strings.TrimSpace(optionID)
if p == nil || optionID == "" {
return false
}
for _, option := range p.options {
if firstNonEmpty(asString(option["optionId"]), asString(option["id"])) == optionID {
return true
}
}
return false
}
func (p *pendingInteractiveRequest) resolvePermissionOptionID(optionID string) (string, bool) {
optionID = strings.TrimSpace(optionID)
if p == nil || optionID == "" {
return "", false
}
if p.hasOption(optionID) {
return optionID, true
}
decision := permissionOptionDecision(optionID)
if decision == "" {
return "", false
}
aliases := permissionOptionDecisionAliases(decision)
for _, option := range p.options {
resolvedOptionID := firstNonEmpty(asString(option["optionId"]), asString(option["id"]))
if resolvedOptionID == "" {
continue
}
for _, value := range []string{
resolvedOptionID,
asString(option["kind"]),
asString(option["name"]),
asString(option["label"]),
} {
token := normalizePermissionOptionToken(value)
if token == "" {
continue
}
for _, alias := range aliases {
if token == alias {
return resolvedOptionID, true
}
}
}
}
return "", false
}
func permissionOptionDecision(value string) string {
switch normalizePermissionOptionToken(value) {
case "approve", "approved", "allow", "allowed", "allowonce", "accept", "accepted", "acceptedits", "confirm", "confirmed", "ok", "proceed", "yes":
return "approved"
case "deny", "denied", "disallow", "reject", "rejected", "rejectonce", "decline", "declined", "no":
return "denied"
default:
return ""
}
}
func permissionOptionDecisionAliases(decision string) []string {
switch decision {
case "approved":
return []string{"approve", "approved", "allow", "allowed", "allowonce", "accept", "accepted", "acceptedits", "confirm", "confirmed", "ok", "proceed", "yes"}
case "denied":
return []string{"deny", "denied", "disallow", "reject", "rejected", "rejectonce", "decline", "declined", "no"}
default:
return nil
}
}
func normalizePermissionOptionToken(value string) string {
var builder strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
builder.WriteRune(r)
}
}
return builder.String()
}
func normalizedPermissionResolvedEvents(session Session, turnID string, pending *pendingInteractiveRequest, response pendingInteractiveResponse, err error) []activityshared.Event {
if pending == nil {
return nil
}
callType := firstNonEmpty(strings.TrimSpace(pending.callType), "approval")
if err != nil {
return []activityshared.Event{normalizedInteractionSupersededEvent(session, turnID, pending), newTurnActivityEventWithID(session, pending.eventID, EventCallFailed, turnID, messageStreamStateFailed, "", pending.name, map[string]any{
"callId": pending.callID,
"callType": callType,
"name": pending.name,
"toolName": pending.toolName,
"status": messageStreamStateFailed,
"error": map[string]any{
"requestId": pending.requestID,
"message": err.Error(),
},
})}
}
return []activityshared.Event{
newTurnActivityEventWithID(session, pending.eventID, EventCallCompleted, turnID, messageStreamStateCompleted, "", pending.name, map[string]any{
"callId": pending.callID,
"callType": callType,
"name": pending.name,
"toolName": pending.toolName,
"status": messageStreamStateCompleted,
"output": pending.resolvedOutput(response),
}),
newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWorking, "", "", map[string]any{
"phase": string(activityshared.TurnPhaseWorking),
"requestId": pending.requestID,
}),
}
}
func normalizedInteractionRequestedEvent(session Session, turnID string, pending *pendingInteractiveRequest) activityshared.Event {
if pending == nil {
return activityshared.Event{}
}
ctx, ok := activityEventContext(session, "interaction:"+pending.requestID+":requested", turnID)
if !ok {
return activityshared.Event{}
}
return activityshared.NewInteractionRequested(ctx, pendingInteractionTransition(turnID, pending))
}
func normalizedInteractionSupersededEvent(session Session, turnID string, pending *pendingInteractiveRequest) activityshared.Event {
if pending == nil {
return activityshared.Event{}
}
ctx, ok := activityEventContext(session, "interaction:"+pending.requestID+":superseded", turnID)
if !ok {
return activityshared.Event{}
}
return activityshared.NewInteractionSuperseded(ctx, pendingInteractionTransition(turnID, pending))
}
func pendingInteractionTransition(turnID string, pending *pendingInteractiveRequest) activityshared.InteractionTransition {
kind := "approval"
switch strings.ToLower(strings.TrimSpace(pending.kind)) {
case "ask-user", "question":
kind = "question"
case "exit-plan", "plan":
kind = "plan"
}
metadata := map[string]any{
"callId": strings.TrimSpace(pending.callID),
"callType": firstNonEmpty(strings.TrimSpace(pending.callType), "approval"),
}
if purpose := strings.TrimSpace(pending.approvalPurpose); purpose != "" {
metadata["approvalPurpose"] = purpose
}
if pending.prompt != nil {
for key, value := range pending.prompt.Metadata {
metadata[key] = value
}
}
metadata["actions"] = normalizedInteractionActions(pending.options)
return activityshared.InteractionTransition{
RequestID: strings.TrimSpace(pending.requestID),
TurnID: firstNonEmptyString(strings.TrimSpace(turnID), strings.TrimSpace(pending.turnID)),
Kind: kind,
ToolName: firstNonEmpty(strings.TrimSpace(pending.toolName), strings.TrimSpace(pending.name)),
Input: persistedInteractionInput(kind, pending.input),
Metadata: clonePayload(metadata),
}
}
// Runtime adapters keep a flat approval input for provider-specific resolver
// compatibility. The durable interaction has one canonical display payload at
// toolCall.input; persisting the same values again at the root nearly doubles
// large approval records and every Message Center projection derived from them.
func persistedInteractionInput(kind string, input map[string]any) map[string]any {
if kind != "approval" {
return clonePayload(input)
}
toolCall := payloadObject(input["toolCall"])
if len(payloadObject(toolCall["input"])) == 0 {
return clonePayload(input)
}
result := map[string]any{}
for _, key := range []string{"requestId", "toolCall", "options"} {
if value, exists := input[key]; exists {
result[key] = clonePayloadValue(value)
}
}
return result
}
func normalizedInteractionActions(options []map[string]any) []any {
actions := make([]any, 0, len(options))
for _, option := range options {
id := firstNonEmpty(asString(option["optionId"]), asString(option["id"]))
if id == "" {
continue
}
label := firstNonEmpty(asString(option["name"]), asString(option["label"]), id)
actions = append(actions, map[string]any{
"id": id, "label": label, "semantic": interactionActionSemantic(asString(option["kind"])),
})
}
return actions
}
func interactionActionSemantic(kind string) string {
switch normalizePermissionOptionToken(kind) {
case "allowonce":
return "approve"
case "allowalways":
return "approve_always"
case "rejectonce":
return "deny"
case "rejectalways":
return "deny_and_stop"
default:
return ""
}
}
func interactiveApprovalOptionID(input SubmitInteractiveInput) string {
if optionID := strings.TrimSpace(input.OptionID); optionID != "" {
return optionID
}
if action := strings.TrimSpace(input.Action); action != "" {
return action
}
if input.Payload != nil {
return strings.TrimSpace(asString(input.Payload["optionId"]))
}
return ""
}
func (p *pendingInteractiveRequest) snapshotPrompt() *SessionInteractivePrompt {
if p == nil {
return nil
}
state := p.disposition()
if state != pendingInteractiveRequestStatePending && state != pendingInteractiveRequestStateResolving {
return nil
}
if p.prompt != nil {
prompt := *p.prompt
prompt.RequestID = firstNonEmpty(strings.TrimSpace(prompt.RequestID), p.requestID)
prompt.ToolName = firstNonEmpty(strings.TrimSpace(prompt.ToolName), p.toolName, p.name)
prompt.Status = firstNonEmpty(strings.TrimSpace(prompt.Status), SessionStatusWaiting)
prompt.Input = clonePayload(prompt.Input)
prompt.Output = clonePayload(prompt.Output)
prompt.Error = clonePayload(prompt.Error)
prompt.Metadata = clonePayload(prompt.Metadata)
return &prompt
}
return &SessionInteractivePrompt{
Kind: "approval",
RequestID: p.requestID,
ToolName: p.name,
Status: SessionStatusWaiting,
Input: p.snapshotApprovalInput(),
Metadata: map[string]any{
"callType": "approval",
"toolName": firstNonEmpty(strings.TrimSpace(p.toolName), p.name),
},
}
}
func (p *pendingInteractiveRequest) resolvedOutput(response pendingInteractiveResponse) map[string]any {
output := map[string]any{
"requestId": p.requestID,
}
if p.callType == "interactive" {
if response.action != "" {
output["action"] = response.action
}
if response.optionID != "" {
output["selectedId"] = strings.TrimSpace(response.optionID)
}
if payload := clonePayload(response.payload); payload != nil {
output["payload"] = payload
}
return output
}
output["selectedId"] = strings.TrimSpace(response.optionID)
return output
}
func (p *pendingInteractiveRequest) snapshotApprovalInput() map[string]any {
input := clonePayload(p.input)
if input == nil {
input = map[string]any{}
}
if _, ok := input["requestId"]; !ok && strings.TrimSpace(p.requestID) != "" {
input["requestId"] = p.requestID
}
if _, ok := input["callId"]; !ok && strings.TrimSpace(p.callID) != "" {
input["callId"] = p.callID
}
if _, ok := input["options"]; !ok {
input["options"] = cloneOptionMaps(p.options)
}
return input
}
func normalizedInteractivePrompt(toolCall map[string]any, options []map[string]any, requestID string) *SessionInteractivePrompt {
toolName := normalizedInteractiveToolName(toolCall)
switch toolName {
case "AskUserQuestion":
input := clonePayload(payloadObject(toolCall["input"]))
if input == nil {
input = map[string]any{}
}
if _, ok := input["questions"]; !ok {
if questions := payloadArray(toolCall["questions"]); len(questions) > 0 {
input["questions"] = questions
}
}
return &SessionInteractivePrompt{
Kind: "ask-user",
RequestID: requestID,
ToolName: toolName,
Status: "waiting_input",
Input: input,
Metadata: map[string]any{
"callType": "interactive",
"interactiveKind": "ask-user",
"toolName": toolName,
"options": cloneOptionMaps(options),
},
}
case "ExitPlanMode":
input := clonePayload(payloadObject(toolCall["input"]))
if input == nil {
input = map[string]any{}
}
return &SessionInteractivePrompt{
Kind: "exit-plan",
RequestID: requestID,
ToolName: toolName,
Status: "waiting_input",
Input: input,
Metadata: map[string]any{
"callType": "interactive",
"interactiveKind": "exit-plan",
"toolName": toolName,
"options": cloneOptionMaps(options),
},
}
default:
return nil
}
}
func normalizedApprovalInput(toolCall map[string]any, options []map[string]any, requestID string, knownInput map[string]any) map[string]any {
displayInput := normalizedApprovalDisplayInput(toolCall, knownInput)
input := map[string]any{
"requestId": requestID,
"toolCall": normalizedApprovalToolCall(toolCall, displayInput),
"options": cloneOptionMaps(options),
}
for key, value := range displayInput {
if _, exists := input[key]; !exists {
input[key] = clonePayloadValue(value)
}
}
return input
}
func normalizedApprovalToolCall(toolCall map[string]any, displayInput map[string]any) map[string]any {
normalized := map[string]any{}
for _, key := range []string{
"toolCallId",
"callId",
"id",
"title",
"name",
"toolName",
"kind",
"status",
"locations",
} {
if value, exists := toolCall[key]; exists {
normalized[key] = clonePayloadValue(value)
}
}
if len(displayInput) > 0 {
normalized["input"] = clonePayload(displayInput)
}
return normalized
}
// normalizedApprovalDisplayInput builds the preview detail (command, path,
// query, URL, prompt, reason, ...) shown on an approval card. Some ACP providers (Cursor) omit
// `rawInput` on the permission request's own `toolCall` and only repeat
// `toolCallId`/`title`/`kind`, so `knownInput` — the input captured from an
// earlier `tool_call`/`tool_call_update` for the same call id, when available
// — is used as a last-resort fallback per field.
func normalizedApprovalDisplayInput(toolCall map[string]any, knownInput map[string]any) map[string]any {
if len(toolCall) == 0 && len(knownInput) == 0 {
return nil
}
displayInput := clonePayload(payloadObject(toolCall["input"]))
if displayInput == nil {
displayInput = clonePayload(payloadObject(toolCall["rawInput"]))
}
if displayInput == nil {
displayInput = clonePayload(payloadObject(toolCall["raw_input"]))
}
if displayInput == nil {
displayInput = clonePayload(payloadObject(toolCall["arguments"]))
}
if displayInput == nil {
displayInput = clonePayload(payloadObject(toolCall["args"]))
}
if displayInput == nil {
displayInput = map[string]any{}
}
for _, key := range []string{
"command",
"cmd",
"description",
"reason",
"grantRoot",
"file_path",
"filePath",
"path",
"notebook_path",
"query",
"search_query",
"searchQuery",
"url",
"uri",
"prompt",
"instruction",
"pattern",
"cwd",
"changes",
"fileChanges",
} {
if _, exists := displayInput[key]; exists {
continue
}
if value, exists := toolCall[key]; exists {
displayInput[key] = clonePayloadValue(value)
continue
}
if value, exists := knownInput[key]; exists {
displayInput[key] = clonePayloadValue(value)
}
}
acpApplyDiffContent(displayInput, toolCall["content"])
if command := normalizedApprovalDisplayCommand(firstNonEmptyShellCommand(displayInput["command"], displayInput["cmd"])); command != "" {
displayInput["command"] = command
delete(displayInput, "cmd")
}
if len(displayInput) == 0 {
return nil
}
return displayInput
}
func normalizedApprovalDisplayCommand(command any) string {
switch typed := command.(type) {
case string:
return strings.TrimSpace(typed)
case []any:
if len(typed) >= 3 {
flag := strings.TrimSpace(asString(typed[len(typed)-2]))
if flag == "-c" || flag == "-lc" {
return strings.TrimSpace(asString(typed[len(typed)-1]))
}
}
parts := make([]string, 0, len(typed))
for _, part := range typed {
if text := strings.TrimSpace(asString(part)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
default:
return ""
}
}
func interactivePromptKind(prompt *SessionInteractivePrompt) string {
if prompt == nil {
return ""
}
return strings.TrimSpace(prompt.Kind)
}
func normalizedInteractiveToolName(toolCall map[string]any) string {
name := firstNonEmpty(
asString(toolCall["name"]),
asString(toolCall["toolName"]),
asString(toolCall["title"]),
)
switch normalizeInteractiveName(name) {
case "askuserquestion":
return "AskUserQuestion"
case "exitplanmode":
return "ExitPlanMode"
default:
return ""
}
}
func normalizeInteractiveName(name string) string {
return strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(name)))
}
func payloadObject(value any) map[string]any {
obj, _ := value.(map[string]any)
return obj
}
func payloadArray(value any) []map[string]any {
items, _ := value.([]map[string]any)
if items != nil {
return cloneOptionMaps(items)
}
if generic, ok := value.([]any); ok {
out := make([]map[string]any, 0, len(generic))
for _, item := range generic {
if obj, ok := item.(map[string]any); ok {
out = append(out, clonePayload(obj))
}
}
return out
}
return nil
}
func asString(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
default:
return ""
}
}
func newSessionActivityEvent(session Session, eventType string, status string, metadata map[string]any) activityshared.Event {
ctx, ok := activityEventContext(session, newID(), "")
if !ok {
return activityshared.Event{}
}
var event activityshared.Event
switch eventType {
case EventSessionStarted:
event = activityshared.NewSessionStarted(ctx)
case EventSessionUpdated:
event = activityshared.NewSessionUpdated(ctx, activityshared.SessionStatus(status))
case EventSessionCompleted:
event = activityshared.NewSessionCompleted(ctx)
case EventSessionFailed:
event = activityshared.NewSessionFailed(ctx)
case EventSessionCanceled:
event = activityshared.NewSessionUpdated(ctx, activityshared.SessionStatusPaused)
default:
return activityshared.Event{}
}
event.Payload.Metadata = clonePayload(metadata)
return event
}
func newTurnActivityEvent(session Session, eventType string, turnID string, status string, role string, content string, payload map[string]any) activityshared.Event {
return newTurnActivityEventWithID(session, newID(), eventType, turnID, status, role, content, payload)
}
func newTurnActivityEventWithID(session Session, eventID string, eventType string, turnID string, status string, role string, content string, payload map[string]any) activityshared.Event {
return newTurnActivityEventWithIDAt(session, eventID, eventType, turnID, status, role, content, payload, 0)
}
func newTurnActivityEventWithIDAt(
session Session,
eventID string,
eventType string,
turnID string,
status string,
role string,
content string,
payload map[string]any,
occurredAtUnixMS int64,
) activityshared.Event {
ctx, ok := activityEventContextAt(session, eventID, turnID, occurredAtUnixMS)
if !ok {
return activityshared.Event{}
}
switch eventType {
case EventTurnStarted:
event := activityshared.NewTurnStarted(ctx, turnID)
event.Payload.Metadata = clonePayload(payload)
return event
case EventTurnUpdated:
phase := activityshared.TurnPhase(firstNonEmpty(payloadString(payload, "phase"), string(activityshared.TurnPhaseWorking)))
event := activityshared.NewTurnUpdated(ctx, turnID, phase)
event.Payload.Metadata = clonePayload(payload)
return event
case EventTurnCompleted:
event := activityshared.NewTurnCompleted(ctx, turnID, activityshared.TurnOutcomeCompleted)
event.Payload.Metadata = clonePayload(payload)
return event
case EventTurnFailed:
event := activityshared.NewTurnFailed(ctx, turnID)
event.Payload.Metadata = clonePayload(payload)
return event
case EventTurnCanceled:
event := activityshared.NewTurnCompleted(ctx, turnID, activityshared.TurnOutcomeInterrupted)
event.Payload.Metadata = clonePayload(payload)
return event
case EventMessage:
messageRole := activityshared.MessageRole(strings.TrimSpace(role))
if messageRole == "" {
messageRole = activityshared.MessageRoleAssistant
}
if status == "" && messageRole == activityshared.MessageRoleUser {
status = messageStreamStateCompleted
}
event := activityshared.NewMessageAppended(
ctx,
messageRole,
content,
messageRole == activityshared.MessageRoleAssistant && payloadString(payload, "kind") != "agent_system_notice",
)
event.Payload.Metadata = clonePayload(payload)
if event.Payload.Metadata == nil {
event.Payload.Metadata = map[string]any{}
}
if strings.TrimSpace(payloadString(event.Payload.Metadata, "messageId")) == "" {
event.Payload.Metadata["messageId"] = eventID
}
if strings.TrimSpace(payloadString(event.Payload.Metadata, "contentMode")) == "" {
event.Payload.Metadata["contentMode"] = messageContentModeSnapshot
}
if status != "" {
event.Payload.Metadata["streamState"] = status
}
return event
case EventCallStarted:
event := activityshared.NewCallStarted(
ctx,
payloadString(payload, "callId"),
firstNonEmpty(payloadString(payload, "callType"), "tool"),
payloadString(payload, "name"),
payloadMap(payload, "input"),
)
if status := payloadString(payload, "status"); status != "" {
event.Payload.Status = status
}
event.Payload.Metadata = clonePayload(payload)
return event
case EventCallCompleted:
event := activityshared.NewCallCompleted(
ctx,
payloadString(payload, "callId"),
firstNonEmpty(payloadString(payload, "callType"), "tool"),
payloadString(payload, "name"),
payloadMap(payload, "output"),
)
if status := payloadString(payload, "status"); status != "" {
event.Payload.Status = status
}
event.Payload.Metadata = clonePayload(payload)
return event
case EventCallFailed:
event := activityshared.NewCallFailed(
ctx,
payloadString(payload, "callId"),
firstNonEmpty(payloadString(payload, "callType"), "tool"),
payloadString(payload, "name"),
payloadMap(payload, "error"),
)
if output := payloadMap(payload, "output"); len(output) > 0 {
event.Payload.Output = output
}
if status := payloadString(payload, "status"); status != "" {
event.Payload.Status = status
}
event.Payload.Metadata = clonePayload(payload)
return event
default:
return activityshared.Event{}
}
}