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