diff --git a/bridges/codex/approvals_test.go b/bridges/codex/approvals_test.go index 6edf9425..3fb3fc0c 100644 --- a/bridges/codex/approvals_test.go +++ b/bridges/codex/approvals_test.go @@ -3,6 +3,7 @@ package codex import ( "context" "encoding/json" + "sync" "testing" "time" @@ -39,16 +40,21 @@ func TestCodex_CommandApproval_RequestBlocksUntilApproved(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) t.Cleanup(cancel) + var mu sync.Mutex var gotPartTypes []string + var gotParts []map[string]any cc := newTestCodexClient(id.UserID("@owner:example.com")) cc.streamEventHook = func(turnID string, seq int, content map[string]any, txnID string) { _ = turnID _ = seq _ = txnID if p, ok := content["part"].(map[string]any); ok { + mu.Lock() + gotParts = append(gotParts, p) if typ, ok := p["type"].(string); ok { gotPartTypes = append(gotPartTypes, typ) } + mu.Unlock() } } @@ -86,12 +92,30 @@ func TestCodex_CommandApproval_RequestBlocksUntilApproved(t *testing.T) { resCh <- res.(map[string]any) }() - // Give the handler a moment to register and start waiting. - time.Sleep(50 * time.Millisecond) + // Poll until the handler has registered the approval. + var pending *bridgeadapter.Pending[*pendingToolApprovalDataCodex] + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + pending = cc.approvalFlow.Get("123") + if pending != nil && pending.Data != nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if pending == nil || pending.Data == nil { + t.Fatalf("expected pending approval") + } + if pending.Data.Presentation.AllowAlways { + t.Fatalf("expected codex approvals to disable always-allow") + } + if pending.Data.Presentation.Title == "" { + t.Fatalf("expected structured presentation title") + } if err := cc.approvalFlow.Resolve("123", bridgeadapter.ApprovalDecisionPayload{ ApprovalID: "123", Approved: true, + Reason: "allow_once", }); err != nil { t.Fatalf("Resolve: %v", err) } @@ -105,16 +129,130 @@ func TestCodex_CommandApproval_RequestBlocksUntilApproved(t *testing.T) { t.Fatalf("timed out waiting for approval handler to return") } - // Ensure we emitted an approval request chunk. - seenApproval := false - for _, typ := range gotPartTypes { - if typ == "tool-approval-request" { - seenApproval = true + mu.Lock() + defer mu.Unlock() + hasRequest := false + hasResponse := false + hasDenied := false + for _, p := range gotParts { + typ, _ := p["type"].(string) + switch typ { + case "tool-approval-request": + hasRequest = true + case "tool-approval-response": + hasResponse = true + if approved, ok := p["approved"].(bool); !ok || !approved { + t.Fatalf("expected approval response approved=true, got %#v", p) + } + case "tool-output-denied": + hasDenied = true + } + } + if !hasRequest || !hasResponse { + t.Fatalf("expected request+response parts, got types %v", gotPartTypes) + } + if hasDenied { + t.Fatalf("unexpected tool-output-denied for approved decision") + } +} + +func TestCodex_CommandApproval_DenyEmitsResponseThenOutputDenied(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + t.Cleanup(cancel) + + var mu sync.Mutex + var gotPartTypes []string + cc := newTestCodexClient(id.UserID("@owner:example.com")) + cc.streamEventHook = func(turnID string, seq int, content map[string]any, txnID string) { + _ = turnID + _ = seq + _ = txnID + if p, ok := content["part"].(map[string]any); ok { + if typ, ok := p["type"].(string); ok { + mu.Lock() + gotPartTypes = append(gotPartTypes, typ) + mu.Unlock() + } + } + } + + portal := &bridgev2.Portal{Portal: &database.Portal{MXID: id.RoomID("!room:example.com")}} + meta := &PortalMetadata{} + state := &streamingState{turnID: "turn_local"} + cc.activeTurns = map[string]*codexActiveTurn{ + codexTurnKey("thr_1", "turn_1"): { + portal: portal, + meta: meta, + state: state, + threadID: "thr_1", + turnID: "turn_1", + model: "gpt-5.1-codex", + }, + } + + paramsRaw, _ := json.Marshal(map[string]any{ + "threadId": "thr_1", + "turnId": "turn_1", + "itemId": "item_1", + "command": "rm -rf /tmp/test", + }) + req := codexrpc.Request{ + ID: json.RawMessage("456"), + Method: "item/commandExecution/requestApproval", + Params: paramsRaw, + } + + resCh := make(chan map[string]any, 1) + go func() { + res, _ := cc.handleCommandApprovalRequest(ctx, req) + resCh <- res.(map[string]any) + }() + + // Poll until the handler has registered the approval. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if pending := cc.approvalFlow.Get("456"); pending != nil && pending.Data != nil { break } + time.Sleep(5 * time.Millisecond) } - if !seenApproval { - t.Fatalf("expected tool-approval-request in parts, got %v", gotPartTypes) + if err := cc.approvalFlow.Resolve("456", bridgeadapter.ApprovalDecisionPayload{ + ApprovalID: "456", + Approved: false, + Reason: "deny", + }); err != nil { + t.Fatalf("Resolve: %v", err) + } + + select { + case res := <-resCh: + if res["decision"] != "decline" { + t.Fatalf("expected decision=decline, got %#v", res) + } + case <-ctx.Done(): + t.Fatalf("timed out waiting for approval handler to return") + } + + mu.Lock() + defer mu.Unlock() + idxResponse := -1 + idxDenied := -1 + for idx, typ := range gotPartTypes { + if typ == "tool-approval-response" && idxResponse < 0 { + idxResponse = idx + } + if typ == "tool-output-denied" && idxDenied < 0 { + idxDenied = idx + } + } + if idxResponse < 0 { + t.Fatalf("expected tool-approval-response in parts, got %v", gotPartTypes) + } + if idxDenied < 0 { + t.Fatalf("expected tool-output-denied in parts, got %v", gotPartTypes) + } + if idxDenied <= idxResponse { + t.Fatalf("expected tool-output-denied after response, got %v", gotPartTypes) } } @@ -161,7 +299,10 @@ func TestCodex_CommandApproval_RejectCrossRoom(t *testing.T) { otherRoom := id.RoomID("!room2:example.com") cc := newTestCodexClient(owner) - cc.registerToolApproval(roomID, "approval-1", "item-1", "commandExecution", 2*time.Second) + cc.registerToolApproval(roomID, "approval-1", "item-1", "commandExecution", bridgeadapter.ApprovalPromptPresentation{ + Title: "Codex command execution", + AllowAlways: false, + }, 2*time.Second) // Register the approval in a second room to test cross-room rejection. // The flow's HandleReaction checks room via RoomIDFromData, so we test @@ -175,4 +316,4 @@ func TestCodex_CommandApproval_RejectCrossRoom(t *testing.T) { } // The RoomIDFromData callback returns roomID, which won't match otherRoom. _ = otherRoom -} +} \ No newline at end of file diff --git a/bridges/codex/backfill.go b/bridges/codex/backfill.go new file mode 100644 index 00000000..b6e70fc6 --- /dev/null +++ b/bridges/codex/backfill.go @@ -0,0 +1,535 @@ +package codex + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + + "github.com/beeper/agentremote/pkg/bridgeadapter" + "github.com/beeper/agentremote/pkg/shared/backfillutil" +) + +const codexThreadListPageSize = 100 + +var codexThreadListSourceKinds = []string{"cli", "vscode", "appServer"} + +type codexThread struct { + ID string `json:"id"` + Preview string `json:"preview"` + Name string `json:"name"` + Cwd string `json:"cwd"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Turns []codexTurn `json:"turns"` +} + +type codexThreadListResponse struct { + Data []codexThread `json:"data"` + NextCursor string `json:"nextCursor"` +} + +type codexThreadReadResponse struct { + Thread codexThread `json:"thread"` +} + +type codexTurn struct { + ID string `json:"id"` + Items []codexTurnItem `json:"items"` +} + +type codexTurnItem struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + Content []codexUserInput `json:"content"` +} + +type codexUserInput struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type codexBackfillEntry struct { + MessageID networkid.MessageID + Sender bridgev2.EventSender + Text string + Role string + TurnID string + Timestamp time.Time + StreamOrder int64 +} + +func (cc *CodexClient) syncStoredCodexThreads(ctx context.Context) error { + if cc == nil || cc.UserLogin == nil || cc.UserLogin.Bridge == nil { + return nil + } + if err := cc.ensureRPC(ctx); err != nil { + return err + } + threads, err := cc.listCodexThreads(ctx) + if err != nil { + return err + } + if len(threads) == 0 { + return nil + } + + portalsByThreadID, err := cc.existingCodexPortalsByThreadID(ctx) + if err != nil { + return err + } + + createdCount := 0 + for _, thread := range threads { + threadID := strings.TrimSpace(thread.ID) + if threadID == "" { + continue + } + portal, created, err := cc.ensureCodexThreadPortal(ctx, portalsByThreadID[threadID], thread) + if err != nil { + cc.log.Warn().Err(err).Str("thread_id", threadID).Msg("Failed to sync Codex thread portal") + continue + } + portalsByThreadID[threadID] = portal + if created { + createdCount++ + } + } + if createdCount > 0 { + cc.log.Info().Int("created_rooms", createdCount).Msg("Synced stored Codex threads into Matrix") + } + return nil +} + +func (cc *CodexClient) existingCodexPortalsByThreadID(ctx context.Context) (map[string]*bridgev2.Portal, error) { + if cc == nil || cc.UserLogin == nil || cc.UserLogin.Bridge == nil || cc.UserLogin.Bridge.DB == nil { + return map[string]*bridgev2.Portal{}, nil + } + userPortals, err := cc.UserLogin.Bridge.DB.UserPortal.GetAllForLogin(ctx, cc.UserLogin.UserLogin) + if err != nil { + return nil, err + } + out := make(map[string]*bridgev2.Portal, len(userPortals)) + for _, userPortal := range userPortals { + if userPortal == nil { + continue + } + portal, err := cc.UserLogin.Bridge.GetExistingPortalByKey(ctx, userPortal.Portal) + if err != nil || portal == nil { + continue + } + meta := portalMeta(portal) + if meta == nil || !meta.IsCodexRoom { + continue + } + threadID := strings.TrimSpace(meta.CodexThreadID) + if threadID == "" { + continue + } + if _, exists := out[threadID]; exists { + continue + } + out[threadID] = portal + } + return out, nil +} + +func (cc *CodexClient) ensureCodexThreadPortal(ctx context.Context, existing *bridgev2.Portal, thread codexThread) (*bridgev2.Portal, bool, error) { + if cc == nil || cc.UserLogin == nil || cc.UserLogin.Bridge == nil { + return nil, false, errors.New("login unavailable") + } + threadID := strings.TrimSpace(thread.ID) + if threadID == "" { + return nil, false, errors.New("missing thread id") + } + + portal := existing + var err error + if portal == nil { + portalKey, err := codexThreadPortalKey(cc.UserLogin.ID, threadID) + if err != nil { + return nil, false, err + } + portal, err = cc.UserLogin.Bridge.GetPortalByKey(ctx, portalKey) + if err != nil { + return nil, false, err + } + } + created := portal.MXID == "" + + if portal.Metadata == nil { + portal.Metadata = &PortalMetadata{} + } + meta := portalMeta(portal) + meta.IsCodexRoom = true + meta.CodexThreadID = threadID + if cwd := strings.TrimSpace(thread.Cwd); cwd != "" { + meta.CodexCwd = cwd + } + meta.AwaitingCwdSetup = strings.TrimSpace(meta.CodexCwd) == "" + + title := codexThreadTitle(thread) + if title == "" { + title = "Codex" + } + meta.Title = title + if meta.Slug == "" { + meta.Slug = codexThreadSlug(threadID) + } + + portal.RoomType = database.RoomTypeDM + portal.OtherUserID = codexGhostID + + info := cc.composeCodexChatInfo(title, true) + if portal.MXID == "" { + portal.Name = title + portal.NameSet = true + if err := portal.Save(ctx); err != nil { + return nil, false, err + } + if err := portal.CreateMatrixRoom(ctx, cc.UserLogin, info); err != nil { + return nil, false, err + } + bridgeadapter.SendAIRoomInfo(ctx, portal, bridgeadapter.AIRoomKindAgent) + if meta.AwaitingCwdSetup { + cc.sendSystemNotice(ctx, portal, "This imported conversation needs a working directory. Send an absolute path or `~/...`.") + } + } else { + if err := portal.Save(ctx); err != nil { + return nil, false, err + } + portal.UpdateInfo(ctx, info, cc.UserLogin, nil, time.Time{}) + bridgeadapter.SendAIRoomInfo(ctx, portal, bridgeadapter.AIRoomKindAgent) + cc.UserLogin.Bridge.WakeupBackfillQueue() + } + + return portal, created, nil +} + +func codexThreadTitle(thread codexThread) string { + if title := strings.TrimSpace(thread.Name); title != "" { + return title + } + preview := strings.TrimSpace(thread.Preview) + if preview == "" { + return "Codex" + } + preview = strings.ReplaceAll(preview, "\r", "") + if line, _, ok := strings.Cut(preview, "\n"); ok { + preview = line + } + const max = 120 + if len(preview) > max { + preview = preview[:max] + } + return strings.TrimSpace(preview) +} + +func codexThreadSlug(threadID string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(threadID))) + return "thread-" + hex.EncodeToString(sum[:6]) +} + +func (cc *CodexClient) listCodexThreads(ctx context.Context) ([]codexThread, error) { + if err := cc.ensureRPC(ctx); err != nil { + return nil, err + } + var ( + cursor string + out []codexThread + seen = make(map[string]struct{}) + ) + for page := 0; page < 1000; page++ { + params := map[string]any{ + "limit": codexThreadListPageSize, + "sourceKinds": codexThreadListSourceKinds, + } + if cursor != "" { + params["cursor"] = cursor + } + + var resp codexThreadListResponse + callCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + err := cc.rpc.Call(callCtx, "thread/list", params, &resp) + cancel() + if err != nil { + return nil, err + } + for _, thread := range resp.Data { + threadID := strings.TrimSpace(thread.ID) + if threadID == "" { + continue + } + if _, exists := seen[threadID]; exists { + continue + } + seen[threadID] = struct{}{} + out = append(out, thread) + } + next := strings.TrimSpace(resp.NextCursor) + if next == "" || next == cursor { + break + } + cursor = next + } + return out, nil +} + +func (cc *CodexClient) readCodexThread(ctx context.Context, threadID string, includeTurns bool) (*codexThread, error) { + if err := cc.ensureRPC(ctx); err != nil { + return nil, err + } + threadID = strings.TrimSpace(threadID) + if threadID == "" { + return nil, errors.New("missing thread id") + } + var resp codexThreadReadResponse + callCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + err := cc.rpc.Call(callCtx, "thread/read", map[string]any{ + "threadId": threadID, + "includeTurns": includeTurns, + }, &resp) + cancel() + if err != nil { + return nil, err + } + return &resp.Thread, nil +} + +func (cc *CodexClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + if params.Portal == nil || params.ThreadRoot != "" { + return nil, nil + } + meta := portalMeta(params.Portal) + if meta == nil || !meta.IsCodexRoom { + return nil, nil + } + threadID := strings.TrimSpace(meta.CodexThreadID) + if threadID == "" { + return nil, nil + } + + thread, err := cc.readCodexThread(ctx, threadID, true) + if err != nil { + return nil, fmt.Errorf("failed to read thread %s: %w", threadID, err) + } + if thread == nil { + return nil, nil + } + entries := codexThreadBackfillEntries(*thread, cc.senderForHuman(), cc.senderForPortal()) + if len(entries) == 0 { + return &bridgev2.FetchMessagesResponse{ + HasMore: false, + Forward: params.Forward, + Cursor: "", + Messages: nil, + }, nil + } + + batch, cursor, hasMore := codexPaginateBackfill(entries, params) + backfill := make([]*bridgev2.BackfillMessage, 0, len(batch)) + for _, entry := range batch { + text := strings.TrimSpace(entry.Text) + if text == "" { + continue + } + backfill = append(backfill, &bridgev2.BackfillMessage{ + ConvertedMessage: codexBackfillConvertedMessage(entry.Role, text, entry.TurnID), + Sender: entry.Sender, + ID: entry.MessageID, + TxnID: networkid.TransactionID(entry.MessageID), + Timestamp: entry.Timestamp, + StreamOrder: entry.StreamOrder, + }) + } + + return &bridgev2.FetchMessagesResponse{ + Messages: backfill, + Cursor: cursor, + HasMore: hasMore, + Forward: params.Forward, + AggressiveDeduplication: true, + ApproxTotalCount: len(entries), + }, nil +} + +func codexBackfillConvertedMessage(role, text, turnID string) *bridgev2.ConvertedMessage { + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + ID: networkid.PartID("0"), + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + }, + Extra: map[string]any{ + "msgtype": event.MsgText, + "body": text, + "m.mentions": map[string]any{}, + }, + DBMetadata: &MessageMetadata{ + BaseMessageMetadata: bridgeadapter.BaseMessageMetadata{ + Role: role, + Body: text, + TurnID: turnID, + }, + }, + }}, + } +} + +func codexThreadBackfillEntries(thread codexThread, humanSender, codexSender bridgev2.EventSender) []codexBackfillEntry { + if len(thread.Turns) == 0 { + return nil + } + baseUnix := thread.CreatedAt + if baseUnix <= 0 { + baseUnix = thread.UpdatedAt + } + if baseUnix <= 0 { + baseUnix = time.Now().UTC().Unix() + } + baseTime := time.Unix(baseUnix, 0).UTC() + nextOrder := baseTime.UnixMilli() * 1000 + + var out []codexBackfillEntry + for idx, turn := range thread.Turns { + userText, assistantText := codexTurnTextPair(turn) + turnID := strings.TrimSpace(turn.ID) + if turnID == "" { + turnID = fmt.Sprintf("turn-%d", idx) + } + turnTime := baseTime.Add(time.Duration(idx*2) * time.Second) + if userText != "" { + out = append(out, codexBackfillEntry{ + MessageID: codexBackfillMessageID(thread.ID, turnID, "user"), + Sender: humanSender, + Text: userText, + Role: "user", + TurnID: turnID, + Timestamp: turnTime, + StreamOrder: nextOrder, + }) + nextOrder++ + } + if assistantText != "" { + out = append(out, codexBackfillEntry{ + MessageID: codexBackfillMessageID(thread.ID, turnID, "assistant"), + Sender: codexSender, + Text: assistantText, + Role: "assistant", + TurnID: turnID, + Timestamp: turnTime.Add(time.Second), + StreamOrder: nextOrder, + }) + nextOrder++ + } + } + return out +} + +func codexTurnTextPair(turn codexTurn) (string, string) { + var userTextParts []string + var assistantOrder []string + assistantTextByID := make(map[string]string) + var assistantLoose []string + + for _, item := range turn.Items { + switch normalizeCodexThreadItemType(item.Type) { + case "usermessage": + for _, input := range item.Content { + if strings.ToLower(strings.TrimSpace(input.Type)) != "text" { + continue + } + text := strings.TrimSpace(input.Text) + if text == "" { + continue + } + userTextParts = append(userTextParts, text) + } + case "agentmessage": + text := strings.TrimSpace(item.Text) + if text == "" { + continue + } + itemID := strings.TrimSpace(item.ID) + if itemID == "" { + assistantLoose = append(assistantLoose, text) + continue + } + if _, exists := assistantTextByID[itemID]; !exists { + assistantOrder = append(assistantOrder, itemID) + } + assistantTextByID[itemID] = text + } + } + + userText := strings.TrimSpace(strings.Join(userTextParts, "\n\n")) + assistantTextParts := make([]string, 0, len(assistantOrder)+len(assistantLoose)) + for _, itemID := range assistantOrder { + if text := strings.TrimSpace(assistantTextByID[itemID]); text != "" { + assistantTextParts = append(assistantTextParts, text) + } + } + assistantTextParts = append(assistantTextParts, assistantLoose...) + assistantText := strings.TrimSpace(strings.Join(assistantTextParts, "\n\n")) + return userText, assistantText +} + +func normalizeCodexThreadItemType(itemType string) string { + normalized := strings.ToLower(strings.TrimSpace(itemType)) + normalized = strings.ReplaceAll(normalized, "_", "") + return normalized +} + +func codexBackfillMessageID(threadID, turnID, role string) networkid.MessageID { + trimmedThreadID := strings.TrimSpace(threadID) + trimmedTurnID := strings.TrimSpace(turnID) + trimmedRole := strings.TrimSpace(role) + hashInput := trimmedThreadID + "\n" + trimmedTurnID + "\n" + trimmedRole + sum := sha256.Sum256([]byte(hashInput)) + return networkid.MessageID("codex:history:" + hex.EncodeToString(sum[:12])) +} + +func codexPaginateBackfill(entries []codexBackfillEntry, params bridgev2.FetchMessagesParams) ([]codexBackfillEntry, networkid.PaginationCursor, bool) { + result := backfillutil.Paginate( + len(entries), + backfillutil.PaginateParams{ + Count: params.Count, + Forward: params.Forward, + Cursor: params.Cursor, + AnchorMessage: params.AnchorMessage, + ForwardAnchorShift: 1, + }, + func(anchor *database.Message) (int, bool) { + return findCodexAnchorIndex(entries, anchor) + }, + func(anchor *database.Message) int { + return backfillutil.IndexAtOrAfter(len(entries), func(i int) time.Time { + return entries[i].Timestamp + }, anchor.Timestamp) + }, + ) + return entries[result.Start:result.End], result.Cursor, result.HasMore +} + +func findCodexAnchorIndex(entries []codexBackfillEntry, anchor *database.Message) (int, bool) { + if anchor == nil || anchor.ID == "" { + return 0, false + } + for idx, entry := range entries { + if entry.MessageID == anchor.ID { + return idx, true + } + } + return 0, false +} \ No newline at end of file diff --git a/bridges/codex/client.go b/bridges/codex/client.go index 0a0e76bc..2570408d 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -33,6 +33,7 @@ import ( ) var _ bridgev2.NetworkAPI = (*CodexClient)(nil) +var _ bridgev2.BackfillingNetworkAPI = (*CodexClient)(nil) var _ bridgev2.DeleteChatHandlingNetworkAPI = (*CodexClient)(nil) var _ bridgev2.IdentifierResolvingNetworkAPI = (*CodexClient)(nil) var _ bridgev2.ContactListingNetworkAPI = (*CodexClient)(nil) @@ -247,12 +248,15 @@ func (cc *CodexClient) GetApprovalHandler() bridgeadapter.ApprovalReactionHandle } func (cc *CodexClient) LogoutRemote(ctx context.Context) { - // Best-effort: ask Codex to forget the account (tokens are managed by Codex under CODEX_HOME). - if err := cc.ensureRPC(cc.backgroundContext(ctx)); err == nil && cc.rpc != nil { - callCtx, cancel := context.WithTimeout(cc.backgroundContext(ctx), 10*time.Second) - defer cancel() - var out map[string]any - _ = cc.rpc.Call(callCtx, "account/logout", nil, &out) + meta := loginMetadata(cc.UserLogin) + // Only managed per-login auth should trigger upstream account/logout. + if !isHostAuthLogin(meta) { + if err := cc.ensureRPC(cc.backgroundContext(ctx)); err == nil && cc.rpc != nil { + callCtx, cancel := context.WithTimeout(cc.backgroundContext(ctx), 10*time.Second) + defer cancel() + var out map[string]any + _ = cc.rpc.Call(callCtx, "account/logout", nil, &out) + } } // Best-effort: remove on-disk Codex state for this login. cc.purgeCodexHomeBestEffort(ctx) @@ -271,6 +275,7 @@ func (cc *CodexClient) LogoutRemote(ctx context.Context) { }) } + func (cc *CodexClient) purgeCodexHomeBestEffort(ctx context.Context) { if cc.UserLogin == nil { return @@ -280,7 +285,7 @@ func (cc *CodexClient) purgeCodexHomeBestEffort(ctx context.Context) { return } // Don't delete unmanaged homes (e.g. the user's own ~/.codex). - if !meta.CodexHomeManaged { + if !isManagedAuthLogin(meta) { return } codexHome := strings.TrimSpace(meta.CodexHome) @@ -357,7 +362,15 @@ func (cc *CodexClient) IsThisUser(ctx context.Context, userID networkid.UserID) func (cc *CodexClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { meta := portalMeta(portal) - return bridgeadapter.BuildChatInfoWithFallback(meta.Title, portal.Name, "Codex", portal.Topic), nil + metaTitle := "" + if meta != nil { + metaTitle = meta.Title + } + if meta == nil || !meta.IsCodexRoom { + return bridgeadapter.BuildChatInfoWithFallback(metaTitle, portal.Name, "Codex", portal.Topic), nil + } + title := codexPortalTitle(portal) + return cc.composeCodexChatInfo(title, strings.TrimSpace(meta.CodexThreadID) != ""), nil } func (cc *CodexClient) GetUserInfo(_ context.Context, _ *bridgev2.Ghost) (*bridgev2.UserInfo, error) { @@ -389,7 +402,8 @@ func (cc *CodexClient) ResolveIdentifier(ctx context.Context, identifier string, if portal == nil { return nil, errors.New("codex chat unavailable") } - chatInfo := cc.composeCodexChatInfo(codexPortalTitle(portal)) + meta := portalMeta(portal) + chatInfo := cc.composeCodexChatInfo(codexPortalTitle(portal), strings.TrimSpace(meta.CodexThreadID) != "") chat = &bridgev2.CreateChatResponse{ PortalKey: portal.PortalKey, PortalInfo: chatInfo, @@ -1439,14 +1453,17 @@ func (cc *CodexClient) scheduleBootstrap() { func (cc *CodexClient) bootstrap(ctx context.Context) { cc.waitForLoginPersisted(ctx) - meta := loginMetadata(cc.UserLogin) - if meta.ChatsSynced { - return - } + syncSucceeded := true if err := cc.ensureDefaultCodexChat(cc.backgroundContext(ctx)); err != nil { cc.log.Warn().Err(err).Msg("Failed to ensure default Codex chat during bootstrap") + syncSucceeded = false + } + if err := cc.syncStoredCodexThreads(cc.backgroundContext(ctx)); err != nil { + cc.log.Warn().Err(err).Msg("Failed to sync Codex threads during bootstrap") + syncSucceeded = false } - meta.ChatsSynced = true + meta := loginMetadata(cc.UserLogin) + meta.ChatsSynced = syncSucceeded _ = cc.UserLogin.Save(ctx) } @@ -1499,7 +1516,7 @@ func (cc *CodexClient) ensureDefaultCodexChat(ctx context.Context) error { } if portal.MXID == "" { - info := cc.composeCodexChatInfo(meta.Title) + info := cc.composeCodexChatInfo(meta.Title, false) if err := portal.CreateMatrixRoom(ctx, cc.UserLogin, info); err != nil { return err } @@ -1520,7 +1537,7 @@ func (cc *CodexClient) ensureDefaultCodexChat(ctx context.Context) error { return nil } -func (cc *CodexClient) composeCodexChatInfo(title string) *bridgev2.ChatInfo { +func (cc *CodexClient) composeCodexChatInfo(title string, canBackfill bool) *bridgev2.ChatInfo { if title == "" { title = "Codex" } @@ -1530,6 +1547,7 @@ func (cc *CodexClient) composeCodexChatInfo(title string) *bridgev2.ChatInfo { LoginID: cc.UserLogin.ID, BotUserID: codexGhostID, BotDisplayName: "Codex", + CanBackfill: canBackfill, CapabilitiesEvent: matrixevents.RoomCapabilitiesEventType, SettingsEvent: matrixevents.RoomSettingsEventType, }) @@ -1720,32 +1738,6 @@ func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Po cc.sendViaPortal(sendCtx, portal, bridgeadapter.BuildSystemNotice(strings.TrimSpace(message)), "") } -func (cc *CodexClient) sendApprovalRequestFallbackEvent( - ctx context.Context, - portal *bridgev2.Portal, - state *streamingState, - approvalID string, - toolCallID string, - toolName string, - ttlSeconds int, -) { - if state == nil { - return - } - cc.approvalFlow.SendPrompt(ctx, portal, bridgeadapter.SendPromptParams{ - ApprovalPromptMessageParams: bridgeadapter.ApprovalPromptMessageParams{ - ApprovalID: approvalID, - ToolCallID: toolCallID, - ToolName: toolName, - TurnID: state.turnID, - ReplyToEventID: state.initialEventID, - ExpiresAt: bridgeadapter.ComputeApprovalExpiry(ttlSeconds), - }, - RoomID: portal.MXID, - OwnerMXID: cc.UserLogin.UserMXID, - }) -} - func (cc *CodexClient) sendPendingStatus(ctx context.Context, portal *bridgev2.Portal, evt *event.Event, message string) { st := bridgev2.MessageStatus{ Status: event.MessageStatusPending, @@ -1920,16 +1912,31 @@ func (cc *CodexClient) ensureUIToolInputStart(ctx context.Context, portal *bridg return } ui := cc.uiEmitter(state) - ui.EnsureUIToolInputStart(ctx, portal, toolCallID, toolName, providerExecuted, false, streamui.ToolDisplayTitle(toolName), nil) + ui.EnsureUIToolInputStart(ctx, portal, toolCallID, toolName, providerExecuted, streamui.ToolDisplayTitle(toolName), nil) ui.EmitUIToolInputAvailable(ctx, portal, toolCallID, toolName, input, providerExecuted) } func (cc *CodexClient) emitUIToolApprovalRequest( ctx context.Context, portal *bridgev2.Portal, state *streamingState, - approvalID, toolCallID, toolName string, ttlSeconds int, + approvalID, toolCallID, toolName string, presentation bridgeadapter.ApprovalPromptPresentation, ttlSeconds int, ) { - cc.uiEmitter(state).EmitUIToolApprovalRequest(ctx, portal, approvalID, toolCallID, toolName, ttlSeconds) - cc.sendApprovalRequestFallbackEvent(ctx, portal, state, approvalID, toolCallID, toolName, ttlSeconds) + cc.uiEmitter(state).EmitUIToolApprovalRequest(ctx, portal, approvalID, toolCallID) + if state == nil { + return + } + cc.approvalFlow.SendPrompt(ctx, portal, bridgeadapter.SendPromptParams{ + ApprovalPromptMessageParams: bridgeadapter.ApprovalPromptMessageParams{ + ApprovalID: approvalID, + ToolCallID: toolCallID, + ToolName: toolName, + TurnID: state.turnID, + Presentation: presentation, + ReplyToEventID: state.initialEventID, + ExpiresAt: bridgeadapter.ComputeApprovalExpiry(ttlSeconds), + }, + RoomID: portal.MXID, + OwnerMXID: cc.UserLogin.UserMXID, + }) } func (cc *CodexClient) emitUIFinish(ctx context.Context, portal *bridgev2.Portal, state *streamingState, model string, finishReason string) { @@ -2066,30 +2073,51 @@ func (cc *CodexClient) saveAssistantMessage(ctx context.Context, portal *bridgev // pendingToolApprovalDataCodex holds codex-specific metadata stored in // ApprovalFlow's Pending.Data field. type pendingToolApprovalDataCodex struct { - ApprovalID string - RoomID id.RoomID - ToolCallID string - ToolName string -} - -func (cc *CodexClient) registerToolApproval(roomID id.RoomID, approvalID, toolCallID, toolName string, ttl time.Duration) (*bridgeadapter.Pending[*pendingToolApprovalDataCodex], bool) { + ApprovalID string + RoomID id.RoomID + ToolCallID string + ToolName string + Presentation bridgeadapter.ApprovalPromptPresentation +} + +func (cc *CodexClient) registerToolApproval( + roomID id.RoomID, + approvalID, toolCallID, toolName string, + presentation bridgeadapter.ApprovalPromptPresentation, + ttl time.Duration, +) (*bridgeadapter.Pending[*pendingToolApprovalDataCodex], bool) { data := &pendingToolApprovalDataCodex{ - ApprovalID: strings.TrimSpace(approvalID), - RoomID: roomID, - ToolCallID: strings.TrimSpace(toolCallID), - ToolName: strings.TrimSpace(toolName), + ApprovalID: strings.TrimSpace(approvalID), + RoomID: roomID, + ToolCallID: strings.TrimSpace(toolCallID), + ToolName: strings.TrimSpace(toolName), + Presentation: presentation, } return cc.approvalFlow.Register(approvalID, ttl, data) } func (cc *CodexClient) waitToolApproval(ctx context.Context, approvalID string) (bridgeadapter.ApprovalDecisionPayload, bool) { - defer cc.approvalFlow.Drop(strings.TrimSpace(approvalID)) - return cc.approvalFlow.Wait(ctx, approvalID) + approvalID = strings.TrimSpace(approvalID) + decision, ok := cc.approvalFlow.Wait(ctx, approvalID) + if !ok { + reason := bridgeadapter.ApprovalReasonTimeout + if ctx.Err() != nil { + reason = bridgeadapter.ApprovalReasonCancelled + } + cc.approvalFlow.FinishResolved(approvalID, bridgeadapter.ApprovalDecisionPayload{ + ApprovalID: approvalID, + Reason: reason, + }) + return decision, false + } + cc.approvalFlow.FinishResolved(approvalID, decision) + return decision, true } func (cc *CodexClient) handleApprovalRequest( ctx context.Context, req codexrpc.Request, - defaultToolName string, extractInput func(json.RawMessage) map[string]any, + defaultToolName string, + extractInput func(json.RawMessage) (map[string]any, bridgeadapter.ApprovalPromptPresentation), ) (any, *codexrpc.RPCError) { approvalID := strings.Trim(string(req.ID), "\"") var params struct { @@ -2115,52 +2143,88 @@ func (cc *CodexClient) handleApprovalRequest( cc.setApprovalStateTracking(active.state, approvalID, toolCallID, toolName) - inputMap := extractInput(req.Params) + inputMap, presentation := extractInput(req.Params) cc.ensureUIToolInputStart(ctx, active.portal, active.state, toolCallID, toolName, true, inputMap) approvalTTL := time.Duration(ttlSeconds) * time.Second - cc.registerToolApproval(active.portal.MXID, approvalID, toolCallID, toolName, approvalTTL) + _, isNew := cc.registerToolApproval(active.portal.MXID, approvalID, toolCallID, toolName, presentation, approvalTTL) + + if !isNew { + // Duplicate approval request; the original handler is already waiting. + // Do not emit UI request or re-enter the wait flow. + decision, ok := cc.waitToolApproval(ctx, approvalID) + if !ok { + return emitOutcome(false, bridgeadapter.ApprovalReasonTimeout) + } + return emitOutcome(decision.Approved, decision.Reason) + } - cc.emitUIToolApprovalRequest(ctx, active.portal, active.state, approvalID, toolCallID, toolName, ttlSeconds) + cc.emitUIToolApprovalRequest(ctx, active.portal, active.state, approvalID, toolCallID, toolName, presentation, ttlSeconds) + + emitOutcome := func(approved bool, reason string) (any, *codexrpc.RPCError) { + cc.uiEmitter(active.state).EmitUIToolApprovalResponse(ctx, active.portal, approvalID, toolCallID, approved, reason) + streamui.RecordApprovalResponse(&active.state.ui, approvalID, toolCallID, approved, reason) + if approved { + return map[string]any{"decision": "accept"}, nil + } + cc.uiEmitter(active.state).EmitUIToolOutputDenied(ctx, active.portal, toolCallID) + return map[string]any{"decision": "decline"}, nil + } if active.meta != nil { if lvl, _ := stringutil.NormalizeElevatedLevel(active.meta.ElevatedLevel); lvl == "full" { - streamui.RecordApprovalResponse(&active.state.ui, approvalID, toolCallID, true, "auto-approved") - return map[string]any{"decision": "accept"}, nil + cc.approvalFlow.FinishResolved(approvalID, bridgeadapter.ApprovalDecisionPayload{ + ApprovalID: approvalID, + Approved: true, + Reason: "auto-approved", + }) + return emitOutcome(true, "auto-approved") } } decision, ok := cc.waitToolApproval(ctx, approvalID) if !ok { - streamui.RecordApprovalResponse(&active.state.ui, approvalID, toolCallID, false, "timeout") - return map[string]any{"decision": "decline"}, nil + return emitOutcome(false, bridgeadapter.ApprovalReasonTimeout) } - streamui.RecordApprovalResponse(&active.state.ui, approvalID, toolCallID, decision.Approved, decision.Reason) - if decision.Approved { - return map[string]any{"decision": "accept"}, nil - } - return map[string]any{"decision": "decline"}, nil + return emitOutcome(decision.Approved, decision.Reason) } func (cc *CodexClient) handleCommandApprovalRequest(ctx context.Context, req codexrpc.Request) (any, *codexrpc.RPCError) { - return cc.handleApprovalRequest(ctx, req, "commandExecution", func(raw json.RawMessage) map[string]any { + return cc.handleApprovalRequest(ctx, req, "commandExecution", func(raw json.RawMessage) (map[string]any, bridgeadapter.ApprovalPromptPresentation) { var p struct { Command *string `json:"command"` Cwd *string `json:"cwd"` Reason *string `json:"reason"` } _ = json.Unmarshal(raw, &p) - return map[string]any{"command": p.Command, "cwd": p.Cwd, "reason": p.Reason} + input := map[string]any{} + details := make([]bridgeadapter.ApprovalDetail, 0, 3) + input, details = bridgeadapter.AddOptionalDetail(input, details, "command", "Command", p.Command) + input, details = bridgeadapter.AddOptionalDetail(input, details, "cwd", "Working directory", p.Cwd) + input, details = bridgeadapter.AddOptionalDetail(input, details, "reason", "Reason", p.Reason) + return input, bridgeadapter.ApprovalPromptPresentation{ + Title: "Codex command execution", + Details: details, + AllowAlways: false, + } }) } func (cc *CodexClient) handleFileChangeApprovalRequest(ctx context.Context, req codexrpc.Request) (any, *codexrpc.RPCError) { - return cc.handleApprovalRequest(ctx, req, "fileChange", func(raw json.RawMessage) map[string]any { + return cc.handleApprovalRequest(ctx, req, "fileChange", func(raw json.RawMessage) (map[string]any, bridgeadapter.ApprovalPromptPresentation) { var p struct { Reason *string `json:"reason"` GrantRoot *string `json:"grantRoot"` } _ = json.Unmarshal(raw, &p) - return map[string]any{"reason": p.Reason, "grantRoot": p.GrantRoot} + input := map[string]any{} + details := make([]bridgeadapter.ApprovalDetail, 0, 2) + input, details = bridgeadapter.AddOptionalDetail(input, details, "grantRoot", "Grant root", p.GrantRoot) + input, details = bridgeadapter.AddOptionalDetail(input, details, "reason", "Reason", p.Reason) + return input, bridgeadapter.ApprovalPromptPresentation{ + Title: "Codex file change", + Details: details, + AllowAlways: false, + } }) } @@ -2190,4 +2254,4 @@ func (cc *CodexClient) setApprovalStateTracking(state *streamingState, approvalI state.ui.UIToolApprovalRequested[approvalID] = true state.ui.UIToolNameByToolCallID[toolCallID] = toolName state.ui.UIToolTypeByToolCallID[toolCallID] = matrixevents.ToolTypeProvider -} +} \ No newline at end of file diff --git a/bridges/codex/metadata.go b/bridges/codex/metadata.go index bfaff709..5082fc4a 100644 --- a/bridges/codex/metadata.go +++ b/bridges/codex/metadata.go @@ -15,12 +15,18 @@ type UserLoginMetadata struct { Provider string `json:"provider,omitempty"` CodexHome string `json:"codex_home,omitempty"` CodexHomeManaged bool `json:"codex_home_managed,omitempty"` + CodexAuthSource string `json:"codex_auth_source,omitempty"` CodexCommand string `json:"codex_command,omitempty"` CodexAuthMode string `json:"codex_auth_mode,omitempty"` CodexAccountEmail string `json:"codex_account_email,omitempty"` ChatsSynced bool `json:"chats_synced,omitempty"` } +const ( + CodexAuthSourceManaged = "managed" + CodexAuthSourceHost = "host" +) + type PortalMetadata struct { Title string `json:"title,omitempty"` Slug string `json:"slug,omitempty"` @@ -89,6 +95,21 @@ func portalMeta(portal *bridgev2.Portal) *PortalMetadata { return bridgeadapter.EnsurePortalMetadata[PortalMetadata](portal) } +func normalizedCodexAuthSource(meta *UserLoginMetadata) string { + if meta == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(meta.CodexAuthSource)) +} + +func isHostAuthLogin(meta *UserLoginMetadata) bool { + return normalizedCodexAuthSource(meta) == CodexAuthSourceHost +} + +func isManagedAuthLogin(meta *UserLoginMetadata) bool { + return normalizedCodexAuthSource(meta) == CodexAuthSourceManaged +} + func NewTurnID() string { return "turn_" + strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "") -} +} \ No newline at end of file diff --git a/bridges/codex/stream_events.go b/bridges/codex/stream_events.go index 5710050a..de8789ef 100644 --- a/bridges/codex/stream_events.go +++ b/bridges/codex/stream_events.go @@ -2,6 +2,8 @@ package codex import ( "fmt" + "net/url" + "strings" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -12,3 +14,20 @@ func defaultCodexChatPortalKey(loginID networkid.UserLoginID) networkid.PortalKe Receiver: loginID, } } + +func codexThreadPortalKey(loginID networkid.UserLoginID, threadID string) (networkid.PortalKey, error) { + trimmed := strings.TrimSpace(threadID) + if trimmed == "" { + return networkid.PortalKey{}, fmt.Errorf("thread ID cannot be empty") + } + return networkid.PortalKey{ + ID: networkid.PortalID( + fmt.Sprintf( + "codex:%s:thread:%s", + loginID, + url.PathEscape(trimmed), + ), + ), + Receiver: loginID, + }, nil +} \ No newline at end of file diff --git a/bridges/openclaw/events.go b/bridges/openclaw/events.go index 3409215f..ee7f95e8 100644 --- a/bridges/openclaw/events.go +++ b/bridges/openclaw/events.go @@ -12,6 +12,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "github.com/beeper/agentremote/pkg/shared/openclawconv" ) type OpenClawSessionResyncEvent struct { @@ -76,9 +78,9 @@ func (evt *OpenClawSessionResyncEvent) GetChatInfo(ctx context.Context, portal * meta.OpenClawSpace = evt.session.Space meta.OpenClawChatType = evt.session.ChatType meta.OpenClawOrigin = evt.session.OriginString() - meta.OpenClawAgentID = stringsTrimDefault(meta.OpenClawAgentID, openClawAgentIDFromSessionKey(evt.session.Key)) + meta.OpenClawAgentID = openclawconv.StringsTrimDefault(meta.OpenClawAgentID, openClawAgentIDFromSessionKey(evt.session.Key)) if isOpenClawSyntheticDMSessionKey(evt.session.Key) { - meta.OpenClawDMTargetAgentID = stringsTrimDefault(meta.OpenClawDMTargetAgentID, openClawAgentIDFromSessionKey(evt.session.Key)) + meta.OpenClawDMTargetAgentID = openclawconv.StringsTrimDefault(meta.OpenClawDMTargetAgentID, openClawAgentIDFromSessionKey(evt.session.Key)) } meta.OpenClawSystemSent = evt.session.SystemSent meta.OpenClawAbortedLastRun = evt.session.AbortedLastRun @@ -100,7 +102,7 @@ func (evt *OpenClawSessionResyncEvent) GetChatInfo(ctx context.Context, portal * meta.LastTo = evt.session.LastTo meta.LastAccountID = evt.session.LastAccountID meta.SessionUpdatedAt = evt.session.UpdatedAt - meta.OpenClawPreviewSnippet = stringsTrimDefault(meta.OpenClawPreviewSnippet, evt.session.LastMessagePreview) + meta.OpenClawPreviewSnippet = openclawconv.StringsTrimDefault(meta.OpenClawPreviewSnippet, evt.session.LastMessagePreview) if meta.OpenClawPreviewSnippet != "" && meta.OpenClawLastPreviewAt == 0 { meta.OpenClawLastPreviewAt = time.Now().UnixMilli() } @@ -119,7 +121,7 @@ func (evt *OpenClawSessionResyncEvent) GetChatInfo(ctx context.Context, portal * }, }, } - agentID := stringsTrimDefault(meta.OpenClawAgentID, "gateway") + agentID := openclawconv.StringsTrimDefault(meta.OpenClawAgentID, "gateway") if strings.TrimSpace(meta.OpenClawDMTargetAgentID) != "" { agentID = strings.TrimSpace(meta.OpenClawDMTargetAgentID) meta.OpenClawAgentID = agentID @@ -240,4 +242,4 @@ func (e *OpenClawRemoteEdit) ConvertEdit(_ context.Context, _ *bridgev2.Portal, func newOpenClawMessageID() networkid.MessageID { return networkid.MessageID("openclaw:" + uuid.NewString()) -} +} \ No newline at end of file diff --git a/bridges/opencode/opencodebridge/opencode_manager.go b/bridges/opencode/opencodebridge/opencode_manager.go index e9f75ad2..38cb79f4 100644 --- a/bridges/opencode/opencodebridge/opencode_manager.go +++ b/bridges/opencode/opencodebridge/opencode_manager.go @@ -35,6 +35,30 @@ type permissionApprovalRef struct { MessageID string ToolCallID string PermissionID string + Presentation bridgeadapter.ApprovalPromptPresentation +} + +func buildOpenCodeApprovalPresentation(req opencode.PermissionRequest) bridgeadapter.ApprovalPromptPresentation { + permission := strings.TrimSpace(req.Permission) + title := "OpenCode permission request" + if permission != "" { + title = "OpenCode permission request: " + permission + } + details := make([]bridgeadapter.ApprovalDetail, 0, 8) + if permission != "" { + details = append(details, bridgeadapter.ApprovalDetail{Label: "Permission", Value: permission}) + } + if v := bridgeadapter.ValueSummary(req.Patterns); v != "" { + details = append(details, bridgeadapter.ApprovalDetail{Label: "Patterns", Value: v}) + } + if len(req.Metadata) > 0 { + details = bridgeadapter.AppendDetailsFromMap(details, "Metadata", req.Metadata, 4) + } + return bridgeadapter.ApprovalPromptPresentation{ + Title: title, + Details: details, + AllowAlways: req.Always, + } } func NewOpenCodeManager(bridge *Bridge) *OpenCodeManager { @@ -73,13 +97,7 @@ func NewOpenCodeManager(bridge *Bridge) *OpenCodeManager { if ref == nil { return bridgeadapter.ErrApprovalUnknown } - response := "reject" - if decision.Approved { - response = "once" - if decision.Always { - response = "always" - } - } + response := bridgeadapter.DecisionToString(decision, "once", "always", "reject") inst, err := mgr.requireConnectedInstance(ref.InstanceID) if err != nil { return err @@ -546,10 +564,17 @@ func (m *OpenCodeManager) UpdateSessionTitle(ctx context.Context, instanceID, se func (m *OpenCodeManager) syncSessions(ctx context.Context, inst *openCodeInstance, sessions []opencode.Session) (int, error) { count := 0 for _, session := range sessions { + hadRoom := false + if portal := m.bridge.findOpenCodePortal(ctx, inst.cfg.ID, session.ID); portal != nil && portal.MXID != "" { + hadRoom = true + } if err := m.bridge.ensureOpenCodeSessionPortal(ctx, inst, session); err != nil { m.log().Warn().Err(err).Str("session", session.ID).Msg("Failed to sync OpenCode session") continue } + if hadRoom { + m.bridge.queueOpenCodeSessionResync(inst.cfg.ID, session) + } count++ } return count, nil @@ -676,8 +701,16 @@ func (m *OpenCodeManager) handleSessionEvent(ctx context.Context, inst *openCode m.log().Warn().Err(err).Msg("Failed to decode session event") return } + hadRoom := false + if portal := m.bridge.findOpenCodePortal(ctx, inst.cfg.ID, session.ID); portal != nil && portal.MXID != "" { + hadRoom = true + } if err := m.bridge.ensureOpenCodeSessionPortal(ctx, inst, session); err != nil { m.log().Warn().Err(err).Str("session", session.ID).Msg("Failed to ensure session portal") + return + } + if hadRoom { + m.bridge.queueOpenCodeSessionResync(inst.cfg.ID, session) } } @@ -816,6 +849,7 @@ func (m *OpenCodeManager) handlePermissionAskedEvent(ctx context.Context, inst * return } approvalID := strings.TrimSpace(req.ID) + presentation := buildOpenCodeApprovalPresentation(req) _, created := m.approvalFlow.Register(approvalID, 10*time.Minute, &permissionApprovalRef{ RoomID: portal.MXID, InstanceID: inst.cfg.ID, @@ -823,6 +857,7 @@ func (m *OpenCodeManager) handlePermissionAskedEvent(ctx context.Context, inst * MessageID: messageID, ToolCallID: toolCallID, PermissionID: approvalID, + Presentation: presentation, }) if !created { return @@ -847,11 +882,12 @@ func (m *OpenCodeManager) handlePermissionAskedEvent(ctx context.Context, inst * } m.approvalFlow.SendPrompt(ctx, portal, bridgeadapter.SendPromptParams{ ApprovalPromptMessageParams: bridgeadapter.ApprovalPromptMessageParams{ - ApprovalID: approvalID, - ToolCallID: toolCallID, - ToolName: toolName, - TurnID: turnID, - ExpiresAt: time.Now().Add(10 * time.Minute), + ApprovalID: approvalID, + ToolCallID: toolCallID, + ToolName: toolName, + TurnID: turnID, + Presentation: presentation, + ExpiresAt: time.Now().Add(10 * time.Minute), }, RoomID: portal.MXID, OwnerMXID: ownerMXID, @@ -899,7 +935,12 @@ func (m *OpenCodeManager) handlePermissionRepliedEvent(ctx context.Context, inst }) } } - m.approvalFlow.Drop(payload.RequestID) + m.approvalFlow.ResolveExternal(ctx, strings.TrimSpace(payload.RequestID), bridgeadapter.ApprovalDecisionPayload{ + ApprovalID: strings.TrimSpace(payload.RequestID), + Approved: approved, + Always: strings.EqualFold(strings.TrimSpace(payload.Reply), "always"), + Reason: reply, + }) } func (m *OpenCodeManager) handleQuestionAskedEvent(ctx context.Context, inst *openCodeInstance, evt opencode.Event) { @@ -1303,4 +1344,4 @@ func findOpenCodePart(parts []opencode.Part, partID string) (opencode.Part, bool } } return opencode.Part{}, false -} +} \ No newline at end of file diff --git a/docs/matrix-ai-matrix-spec-v1.md b/docs/matrix-ai-matrix-spec-v1.md index 5a6d4c73..0f398700 100644 --- a/docs/matrix-ai-matrix-spec-v1.md +++ b/docs/matrix-ai-matrix-spec-v1.md @@ -88,7 +88,6 @@ Authoritative identifiers are defined in `pkg/matrixevents/matrixevents.go`. | Key | Where it appears | Purpose | Spec section | | --- | --- | --- | --- | | `com.beeper.ai` | `m.room.message` | Canonical assistant `UIMessage` | [Canonical](#canonical) | -| `com.beeper.ai.approval_decision` | `m.room.message` | Owner approval response for pending tool requests | [Approvals](#approvals-decision) | | `com.beeper.ai.model_id` | `m.room.message` | Routing/display hint | [Other keys](#other-keys-routing) | | `com.beeper.ai.agent` | `m.room.message`, `m.room.member` | Routing hint or agent definition | [Other keys](#other-keys-agent) | | `com.beeper.ai.image_generation` | `m.room.message` (image) | Generated-image tag/metadata | [Other keys](#other-keys-media) | @@ -304,9 +303,16 @@ When approval is needed, the bridge emits: 1. An ephemeral stream chunk (`com.beeper.ai.stream_event`) where `part.type = "tool-approval-request"` containing: - `approvalId: string` - `toolCallId: string` -2. A timeline-visible fallback notice (for clients that drop/ignore ephemeral events). +2. A timeline-visible canonical approval notice. - The notice is an `m.room.message` with `msgtype = "m.notice"`, SHOULD reply to the originating assistant turn via `m.relates_to.m.in_reply_to`, and includes a complete `com.beeper.ai` `UIMessage` using the canonical shape defined above (`id`, `role`, optional `metadata`, `parts`). - - That fallback `UIMessage.metadata` contains `approvalId` and its `parts` contains a `dynamic-tool` part with: + - The notice body MUST list the canonical reaction keys for the available options. + - The bridge MUST send bridge-authored placeholder `m.reaction` events that use the `m.annotation` relation type on the notice, one for each allowed option key. + - `UIMessage.metadata.approval` SHOULD include: + - `id: string` + - `options: [{ id, key, label, approved, always?, reason? }]` + - `presentation` + - `expiresAt` when known + - The `dynamic-tool` part contains: - `state = "approval-requested"` - `toolCallId: string` - `toolName: string` @@ -318,42 +324,35 @@ Canonical approval data in persisted `dynamic-tool` parts follows the AI SDK: ### Approving / Denying -Approvals are resolved through a canonical owner reply event: +Approvals are resolved through reactions on the canonical approval notice: -1. **Bridge sends** canonical tool state in `com.beeper.ai` and/or `com.beeper.ai.stream_event` with: - - `part.type = "tool-approval-request"` during streaming - - a persisted `dynamic-tool` part with approval metadata in the final `UIMessage` - -2. **Client sends** a standard `m.room.message` whose content includes `com.beeper.ai.approval_decision` and SHOULD reply to the originating assistant turn via `m.relates_to.m.in_reply_to`: +1. **Bridge sends** the canonical approval notice and placeholder reactions for the allowed option keys. +2. **Owner reacts** to that notice using one of the advertised option keys: ```json { - "type": "m.room.message", + "type": "m.reaction", "content": { - "msgtype": "m.text", - "body": "Approved", "m.relates_to": { - "m.in_reply_to": { "event_id": "$assistant_turn" } - }, - "com.beeper.ai.approval_decision": { - "approvalId": "abc123", - "approved": true, - "always": false + "rel_type": "m.annotation", + "event_id": "$approval_notice", + "key": "approval.allow_once" } } } ``` Rules: -- `approvalId` is required. -- `approved` is required and is the canonical allow/deny decision. -- `always` is optional and, when `true`, persists an allow rule for future matching approvals. -- `reason` is optional. -- Approval decision events are control events. They MUST NOT create a user turn in canonical replay history. -- Timeline fallback notices are UI affordances only. They MUST NOT be projected into provider replay history. +- The approval notice is the canonical Matrix artifact. Rich clients MAY also observe mirrored `tool-approval-request` / `tool-approval-response` stream parts. +- Only owner reactions with an advertised option key can resolve the approval. +- Non-owner reactions and invalid keys MUST be rejected and SHOULD be redacted. +- On terminal completion, the bridge MUST edit the approval notice into its final state and redact all bridge-authored placeholder reactions. +- The resolving owner reaction MUST remain visible. +- If the approval was resolved outside Matrix, the bridge SHOULD mirror the owner's chosen reaction into Matrix before terminal cleanup so the notice stays in sync. +- Approval notices and their terminal edits remain excluded from provider replay history. Always-allow: -- `always: true` persists an allow rule in login metadata, scoped to the current login/account for the current bridge implementation. +- Reacting with the `allow always` option persists an allow rule in login metadata, scoped to the current login/account for the current bridge implementation. - A stored rule matches on the approval target identity emitted by the bridge for that login: at minimum `toolName`, plus any bridge-emitted qualifier needed to distinguish separate approval surfaces for that login (for example agent/model or room-scoped tool routing). - Rules are allow-only. If multiple stored rules match, the most specific rule for the current login wins; otherwise any matching allow rule MAY be applied. - Approval events themselves remain the audit record for the concrete `approvalId`; persisted allow rules are derived from those events and do not change canonical replay history. @@ -415,4 +414,4 @@ Examples: ## Forward Compatibility - Clients MUST ignore unknown `com.beeper.ai.*` event types and unknown fields. -- Clients MUST ignore unknown future streaming chunk types. +- Clients MUST ignore unknown future streaming chunk types. \ No newline at end of file diff --git a/pkg/bridgeadapter/approval_prompt.go b/pkg/bridgeadapter/approval_prompt.go index 6c25b654..fc5e4e4e 100644 --- a/pkg/bridgeadapter/approval_prompt.go +++ b/pkg/bridgeadapter/approval_prompt.go @@ -1,20 +1,28 @@ package bridgeadapter import ( + "encoding/json" "fmt" + "sort" "strings" "time" "go.mau.fi/util/variationselector" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/beeper/agentremote/pkg/matrixevents" ) -const ApprovalDecisionKey = "com.beeper.ai.approval_decision" - const ( + ApprovalPromptStateRequested = "approval-requested" + ApprovalPromptStateResponded = "approval-responded" + + ApprovalReactionKeyAllowOnce = "approval.allow_once" + ApprovalReactionKeyAllowAlways = "approval.allow_always" + ApprovalReactionKeyDeny = "approval.deny" + RejectReasonOwnerOnly = "only_owner" RejectReasonExpired = "expired" RejectReasonInvalidOption = "invalid_option" @@ -30,6 +38,111 @@ type ApprovalOption struct { Reason string `json:"reason,omitempty"` } +type ApprovalDetail struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type ApprovalPromptPresentation struct { + Title string `json:"title"` + Details []ApprovalDetail `json:"details,omitempty"` + AllowAlways bool `json:"allowAlways,omitempty"` +} + +// AppendDetailsFromMap appends approval details from a string-keyed map, sorted by key, +// with a truncation notice if the map exceeds max entries. +func AppendDetailsFromMap(details []ApprovalDetail, labelPrefix string, values map[string]any, max int) []ApprovalDetail { + if len(values) == 0 || max <= 0 { + return details + } + keys := make([]string, 0, len(values)) + for key := range values { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + count := 0 + for _, key := range keys { + if count >= max { + break + } + trimmed := strings.TrimSpace(key) + if value := ValueSummary(values[key]); value != "" { + details = append(details, ApprovalDetail{ + Label: fmt.Sprintf("%s %s", labelPrefix, trimmed), + Value: value, + }) + count++ + } + } + if len(keys) > max { + details = append(details, ApprovalDetail{ + Label: "Input", + Value: fmt.Sprintf("%d additional field(s)", len(keys)-max), + }) + } + return details +} + +// ValueSummary returns a human-readable summary of a value for approval detail display. +func ValueSummary(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case *string: + if typed == nil { + return "" + } + return strings.TrimSpace(*typed) + case bool: + if typed { + return "true" + } + return "false" + case int, int8, int16, int32, int64, float32, float64, uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%v", typed) + case []string: + items := make([]string, 0, len(typed)) + for _, item := range typed { + if trimmed := strings.TrimSpace(item); trimmed != "" { + items = append(items, trimmed) + } + } + if len(items) == 0 { + return "" + } + if len(items) > 3 { + return fmt.Sprintf("%s (+%d more)", strings.Join(items[:3], ", "), len(items)-3) + } + return strings.Join(items, ", ") + case []any: + if len(typed) == 0 { + return "" + } + return fmt.Sprintf("%d item(s)", len(typed)) + case map[string]any: + if len(typed) == 0 { + return "" + } + return fmt.Sprintf("%d field(s)", len(typed)) + default: + encoded, err := json.Marshal(typed) + if err != nil { + return "" + } + serialized := strings.TrimSpace(string(encoded)) + if len(serialized) > 160 { + return serialized[:160] + "..." + } + return serialized + } +} + func (o ApprovalOption) decisionReason() string { if reason := strings.TrimSpace(o.Reason); reason != "" { return reason @@ -60,39 +173,46 @@ func (o ApprovalOption) prefillKeys() []string { return keys } -func DefaultApprovalOptions() []ApprovalOption { - return []ApprovalOption{ +func ApprovalPromptOptions(allowAlways bool) []ApprovalOption { + options := []ApprovalOption{ { ID: "allow_once", - Key: "✅", + Key: ApprovalReactionKeyAllowOnce, Label: "Approve once", Approved: true, Reason: "allow_once", }, + { + ID: "deny", + Key: ApprovalReactionKeyDeny, + Label: "Deny", + Approved: false, + Reason: "deny", + }, + } + if !allowAlways { + return options + } + return []ApprovalOption{ + options[0], { ID: "allow_always", - Key: "🔁", + Key: ApprovalReactionKeyAllowAlways, Label: "Always allow", Approved: true, Always: true, Reason: "allow_always", }, - { - ID: "deny", - Key: "❌", - Label: "Deny", - Approved: false, - Reason: "deny", - }, + options[1], } } -func BuildApprovalPromptBody(toolName string, options []ApprovalOption) string { - toolName = strings.TrimSpace(toolName) - if toolName == "" { - toolName = "tool" - } - actionHints := make([]string, 0, len(options)) +func DefaultApprovalOptions() []ApprovalOption { + return ApprovalPromptOptions(true) +} + +func renderApprovalOptionHints(options []ApprovalOption) []string { + hints := make([]string, 0, len(options)) for _, opt := range options { key := strings.TrimSpace(opt.Key) if key == "" { @@ -102,12 +222,57 @@ func BuildApprovalPromptBody(toolName string, options []ApprovalOption) string { if key == "" || label == "" { continue } - actionHints = append(actionHints, fmt.Sprintf("%s %s", key, label)) + hints = append(hints, fmt.Sprintf("%s = %s", key, label)) + } + return hints +} + +func approvalPromptTitle(presentation ApprovalPromptPresentation, fallbackToolName string) string { + title := strings.TrimSpace(presentation.Title) + if title != "" { + return title + } + fallbackToolName = strings.TrimSpace(fallbackToolName) + if fallbackToolName == "" { + return "tool" + } + return fallbackToolName +} + +func buildApprovalBodyHeader(presentation ApprovalPromptPresentation) []string { + title := approvalPromptTitle(presentation, "") + lines := []string{fmt.Sprintf("Approval required: %s", title)} + for _, detail := range presentation.Details { + label := strings.TrimSpace(detail.Label) + value := strings.TrimSpace(detail.Value) + if label == "" || value == "" { + continue + } + lines = append(lines, fmt.Sprintf("%s: %s", label, value)) + } + return lines +} + +func BuildApprovalPromptBody(presentation ApprovalPromptPresentation, options []ApprovalOption) string { + lines := buildApprovalBodyHeader(presentation) + hints := renderApprovalOptionHints(options) + if len(hints) == 0 { + lines = append(lines, "React to approve or deny.") + return strings.Join(lines, "\n") } - if len(actionHints) == 0 { - return fmt.Sprintf("Approval required for %s.", toolName) + lines = append(lines, "React with: "+strings.Join(hints, ", ")) + return strings.Join(lines, "\n") +} + +func BuildApprovalResponseBody(presentation ApprovalPromptPresentation, decision ApprovalDecisionPayload) string { + lines := buildApprovalBodyHeader(presentation) + outcome, reason := approvalDecisionOutcome(decision) + line := "Decision: " + outcome + if reason != "" { + line += " (reason: " + reason + ")" } - return fmt.Sprintf("Approval required for %s. React with: %s.", toolName, strings.Join(actionHints, ", ")) + lines = append(lines, line) + return strings.Join(lines, "\n") } type ApprovalPromptMessageParams struct { @@ -115,17 +280,29 @@ type ApprovalPromptMessageParams struct { ToolCallID string ToolName string TurnID string - Body string + Presentation ApprovalPromptPresentation ReplyToEventID id.EventID ExpiresAt time.Time Options []ApprovalOption } +type ApprovalResponsePromptMessageParams struct { + ApprovalID string + ToolCallID string + ToolName string + TurnID string + Presentation ApprovalPromptPresentation + Options []ApprovalOption + Decision ApprovalDecisionPayload + ExpiresAt time.Time +} + type ApprovalPromptMessage struct { - Body string - UIMessage map[string]any - Raw map[string]any - Options []ApprovalOption + Body string + UIMessage map[string]any + Raw map[string]any + Presentation ApprovalPromptPresentation + Options []ApprovalOption } func BuildApprovalPromptMessage(params ApprovalPromptMessageParams) ApprovalPromptMessage { @@ -133,23 +310,21 @@ func BuildApprovalPromptMessage(params ApprovalPromptMessageParams) ApprovalProm toolCallID := strings.TrimSpace(params.ToolCallID) toolName := strings.TrimSpace(params.ToolName) turnID := strings.TrimSpace(params.TurnID) - options := normalizeApprovalOptions(params.Options) if toolCallID == "" { toolCallID = approvalID } if toolName == "" { toolName = "tool" } - body := strings.TrimSpace(params.Body) - if body == "" { - body = BuildApprovalPromptBody(toolName, options) - } - metadata := map[string]any{ - "approvalId": approvalID, - } - if turnID != "" { - metadata["turn_id"] = turnID + presentation := normalizeApprovalPromptPresentation(params.Presentation, toolName) + var options []ApprovalOption + if len(params.Options) > 0 { + options = normalizeApprovalOptions(params.Options) + } else { + options = normalizeApprovalOptions(ApprovalPromptOptions(presentation.AllowAlways)) } + body := BuildApprovalPromptBody(presentation, options) + metadata := approvalMessageMetadata(approvalID, turnID, presentation, options, nil, params.ExpiresAt) uiMessage := map[string]any{ "id": approvalID, "role": "assistant", @@ -158,31 +333,17 @@ func BuildApprovalPromptMessage(params ApprovalPromptMessageParams) ApprovalProm "type": "dynamic-tool", "toolName": toolName, "toolCallId": toolCallID, - "state": "approval-requested", + "state": ApprovalPromptStateRequested, "approval": map[string]any{ "id": approvalID, }, }}, } - approvalMeta := map[string]any{ - "kind": "request", - "approvalId": approvalID, - "toolCallId": toolCallID, - "toolName": toolName, - "options": optionsToRaw(options), - } - if turnID != "" { - approvalMeta["turnId"] = turnID - } - if !params.ExpiresAt.IsZero() { - approvalMeta["expiresAt"] = params.ExpiresAt.UnixMilli() - } raw := map[string]any{ "msgtype": event.MsgNotice, "body": body, "m.mentions": map[string]any{}, matrixevents.BeeperAIKey: uiMessage, - ApprovalDecisionKey: approvalMeta, } if params.ReplyToEventID != "" { raw["m.relates_to"] = map[string]any{ @@ -192,23 +353,146 @@ func BuildApprovalPromptMessage(params ApprovalPromptMessageParams) ApprovalProm } } return ApprovalPromptMessage{ - Body: body, - UIMessage: uiMessage, - Raw: raw, - Options: options, + Body: body, + UIMessage: uiMessage, + Raw: raw, + Presentation: presentation, + Options: options, + } +} + +func BuildApprovalResponsePromptMessage(params ApprovalResponsePromptMessageParams) ApprovalPromptMessage { + approvalID := strings.TrimSpace(params.ApprovalID) + toolCallID := strings.TrimSpace(params.ToolCallID) + toolName := strings.TrimSpace(params.ToolName) + turnID := strings.TrimSpace(params.TurnID) + if toolCallID == "" { + toolCallID = approvalID + } + if toolName == "" { + toolName = "tool" + } + presentation := normalizeApprovalPromptPresentation(params.Presentation, toolName) + decision := params.Decision + decision.ApprovalID = strings.TrimSpace(decision.ApprovalID) + if decision.ApprovalID == "" { + decision.ApprovalID = approvalID + } + body := BuildApprovalResponseBody(presentation, decision) + approvalPayload := map[string]any{ + "id": approvalID, + "approved": decision.Approved, + } + if decision.Always { + approvalPayload["always"] = true + } + if strings.TrimSpace(decision.Reason) != "" { + approvalPayload["reason"] = strings.TrimSpace(decision.Reason) + } + options := params.Options + if len(options) > 0 { + options = normalizeApprovalOptions(options) + } else { + options = normalizeApprovalOptions(ApprovalPromptOptions(presentation.AllowAlways)) + } + metadata := approvalMessageMetadata(approvalID, turnID, presentation, options, &decision, params.ExpiresAt) + uiMessage := map[string]any{ + "id": approvalID, + "role": "assistant", + "metadata": metadata, + "parts": []map[string]any{{ + "type": "dynamic-tool", + "toolName": toolName, + "toolCallId": toolCallID, + "state": ApprovalPromptStateResponded, + "approval": approvalPayload, + }}, + } + raw := map[string]any{ + "msgtype": event.MsgNotice, + "body": body, + "m.mentions": map[string]any{}, + matrixevents.BeeperAIKey: uiMessage, + } + return ApprovalPromptMessage{ + Body: body, + UIMessage: uiMessage, + Raw: raw, + Presentation: presentation, + Options: options, + } +} + +func approvalMessageMetadata( + approvalID, turnID string, + presentation ApprovalPromptPresentation, + options []ApprovalOption, + decision *ApprovalDecisionPayload, + expiresAt time.Time, +) map[string]any { + metadata := map[string]any{ + "approvalId": approvalID, + } + if turnID != "" { + metadata["turn_id"] = turnID + } + approval := map[string]any{ + "id": approvalID, + "options": optionsToRaw(options), + "renderedKeys": renderApprovalOptionHints(options), + "presentation": presentationToRaw(presentation), + } + if !expiresAt.IsZero() { + approval["expiresAt"] = expiresAt.UnixMilli() + } + if decision != nil { + approval["approved"] = decision.Approved + if decision.Always { + approval["always"] = true + } + if strings.TrimSpace(decision.Reason) != "" { + approval["reason"] = strings.TrimSpace(decision.Reason) + } + } + metadata["approval"] = approval + return metadata +} + +func approvalDecisionOutcome(decision ApprovalDecisionPayload) (string, string) { + reason := strings.TrimSpace(decision.Reason) + switch { + case decision.Approved && decision.Always: + return "approved (always allow)", "" + case decision.Approved: + return "approved", "" + case reason == ApprovalReasonTimeout: + return "timed out", "" + case reason == ApprovalReasonExpired: + return "expired", "" + case reason == ApprovalReasonDeliveryError: + return "delivery error", "" + case reason == ApprovalReasonCancelled: + return "cancelled", "" + case reason == "": + return "denied", "" + default: + return "denied", reason } } type ApprovalPromptRegistration struct { - ApprovalID string - RoomID id.RoomID - OwnerMXID id.UserID - ToolCallID string - ToolName string - TurnID string - ExpiresAt time.Time - Options []ApprovalOption - PromptEventID id.EventID + ApprovalID string + RoomID id.RoomID + OwnerMXID id.UserID + ToolCallID string + ToolName string + TurnID string + Presentation ApprovalPromptPresentation + ExpiresAt time.Time + Options []ApprovalOption + PromptEventID id.EventID + PromptMessageID networkid.MessageID + PromptSenderID networkid.UserID } type ApprovalPromptReactionMatch struct { @@ -248,6 +532,56 @@ func optionsToRaw(options []ApprovalOption) []map[string]any { return out } +func presentationToRaw(p ApprovalPromptPresentation) map[string]any { + out := map[string]any{ + "title": p.Title, + } + if p.AllowAlways { + out["allowAlways"] = true + } + if len(p.Details) > 0 { + details := make([]map[string]any, 0, len(p.Details)) + for _, detail := range p.Details { + if strings.TrimSpace(detail.Label) == "" || strings.TrimSpace(detail.Value) == "" { + continue + } + details = append(details, map[string]any{ + "label": detail.Label, + "value": detail.Value, + }) + } + if len(details) > 0 { + out["details"] = details + } + } + return out +} + +func normalizeApprovalPromptPresentation(presentation ApprovalPromptPresentation, fallbackToolName string) ApprovalPromptPresentation { + presentation.Title = strings.TrimSpace(presentation.Title) + if presentation.Title == "" { + fallbackToolName = strings.TrimSpace(fallbackToolName) + if fallbackToolName == "" { + fallbackToolName = "tool" + } + presentation.Title = fallbackToolName + } + if len(presentation.Details) == 0 { + return presentation + } + normalized := make([]ApprovalDetail, 0, len(presentation.Details)) + for _, detail := range presentation.Details { + detail.Label = strings.TrimSpace(detail.Label) + detail.Value = strings.TrimSpace(detail.Value) + if detail.Label == "" || detail.Value == "" { + continue + } + normalized = append(normalized, detail) + } + presentation.Details = normalized + return presentation +} + func normalizeApprovalOptions(options []ApprovalOption) []ApprovalOption { if len(options) == 0 { options = DefaultApprovalOptions() @@ -276,10 +610,35 @@ func normalizeApprovalOptions(options []ApprovalOption) []ApprovalOption { return out } +// AddOptionalDetail appends an approval detail from an optional string pointer. +// If the pointer is nil or empty, input and details are returned unchanged. +func AddOptionalDetail(input map[string]any, details []ApprovalDetail, key, label string, ptr *string) (map[string]any, []ApprovalDetail) { + if v := ValueSummary(ptr); v != "" { + if input == nil { + input = make(map[string]any) + } + input[key] = v + details = append(details, ApprovalDetail{Label: label, Value: v}) + } + return input, details +} + +// DecisionToString maps an ApprovalDecisionPayload to one of three upstream +// string values (once/always/deny) based on the decision fields. +func DecisionToString(decision ApprovalDecisionPayload, once, always, deny string) string { + if !decision.Approved { + return deny + } + if decision.Always { + return always + } + return once +} + func normalizeReactionKey(key string) string { key = strings.TrimSpace(key) if key == "" { return "" } return variationselector.Remove(key) -} +} \ No newline at end of file diff --git a/pkg/bridgeadapter/base_reaction_handler.go b/pkg/bridgeadapter/base_reaction_handler.go index b288c6eb..7fc8ee13 100644 --- a/pkg/bridgeadapter/base_reaction_handler.go +++ b/pkg/bridgeadapter/base_reaction_handler.go @@ -3,6 +3,7 @@ package bridgeadapter import ( "context" + "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" ) @@ -34,6 +35,10 @@ func (h BaseReactionHandler) HandleMatrixReaction(ctx context.Context, msg *brid if login != nil && IsMatrixBotUser(ctx, login.Bridge, msg.Event.Sender) { return &database.Reaction{}, nil } + // Best-effort persistence guard for reaction.sender_id -> ghost.id FK. + if err := EnsureSyntheticReactionSenderGhost(ctx, login, msg.Event.Sender); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to ensure synthetic reaction sender ghost") + } rc := ExtractReactionContext(msg) if handler := h.Target.GetApprovalHandler(); handler != nil { handler.HandleReaction(ctx, msg, rc.TargetEventID, rc.Emoji) @@ -43,4 +48,4 @@ func (h BaseReactionHandler) HandleMatrixReaction(ctx context.Context, msg *brid func (h BaseReactionHandler) HandleMatrixReactionRemove(_ context.Context, _ *bridgev2.MatrixReactionRemove) error { return nil -} +} \ No newline at end of file