From 61ad3b5b0ebf5b0567fbb2dc498ce67fac8c0464 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 18:17:39 +0800 Subject: [PATCH 01/24] feat(remote-bridge): standalone Go chat companion bridge Self-contained Go module (own go.mod, no app/electron deps) wrapping github.com/chenhg5/cc-connect platform adapters: bridge engine, gateway, pawwork client + event stream, session pointers, platform registry. ~51% tests. Split out of #1275 as PR 1/2 for reviewability. --- .github/workflows/build.yml | 15 + .../cmd/pawwork-remote-bridge/main.go | 50 ++ packages/remote-bridge/go.mod | 20 + packages/remote-bridge/go.sum | 73 ++ .../remote-bridge/internal/bridge/engine.go | 811 ++++++++++++++++++ .../internal/bridge/engine_test.go | 803 +++++++++++++++++ .../internal/bridge/session_pointers.go | 308 +++++++ .../internal/bridge/session_pointers_test.go | 80 ++ .../remote-bridge/internal/gateway/gateway.go | 258 ++++++ .../internal/gateway/gateway_test.go | 558 ++++++++++++ .../remote-bridge/internal/pawwork/client.go | 378 ++++++++ .../internal/pawwork/client_test.go | 551 ++++++++++++ .../remote-bridge/internal/pawwork/events.go | 406 +++++++++ .../internal/pawwork/events_test.go | 406 +++++++++ .../internal/platforms/platforms.go | 25 + .../internal/platforms/platforms_test.go | 64 ++ 16 files changed, 4806 insertions(+) create mode 100644 packages/remote-bridge/cmd/pawwork-remote-bridge/main.go create mode 100644 packages/remote-bridge/go.mod create mode 100644 packages/remote-bridge/go.sum create mode 100644 packages/remote-bridge/internal/bridge/engine.go create mode 100644 packages/remote-bridge/internal/bridge/engine_test.go create mode 100644 packages/remote-bridge/internal/bridge/session_pointers.go create mode 100644 packages/remote-bridge/internal/bridge/session_pointers_test.go create mode 100644 packages/remote-bridge/internal/gateway/gateway.go create mode 100644 packages/remote-bridge/internal/gateway/gateway_test.go create mode 100644 packages/remote-bridge/internal/pawwork/client.go create mode 100644 packages/remote-bridge/internal/pawwork/client_test.go create mode 100644 packages/remote-bridge/internal/pawwork/events.go create mode 100644 packages/remote-bridge/internal/pawwork/events_test.go create mode 100644 packages/remote-bridge/internal/platforms/platforms.go create mode 100644 packages/remote-bridge/internal/platforms/platforms_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6abcd7faf..b1c4fae67 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -273,6 +273,21 @@ jobs: with: node-version: "24" + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + go-version-file: packages/remote-bridge/go.mod + cache-dependency-path: packages/remote-bridge/go.sum + + - name: Test remote bridge + if: ${{ inputs.phase != 'finalize' }} + run: go test ./... + working-directory: packages/remote-bridge + + - name: Race test remote bridge + if: ${{ inputs.phase != 'finalize' }} + run: go test -race ./internal/bridge ./internal/gateway ./internal/pawwork + working-directory: packages/remote-bridge + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/packages/remote-bridge/cmd/pawwork-remote-bridge/main.go b/packages/remote-bridge/cmd/pawwork-remote-bridge/main.go new file mode 100644 index 000000000..e0e84523a --- /dev/null +++ b/packages/remote-bridge/cmd/pawwork-remote-bridge/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/gateway" + "github.com/astro-han/pawwork/packages/remote-bridge/internal/platforms" +) + +func main() { + configPath := flag.String("config", "", "Path to the remote bridge JSON config. Use - to read from stdin.") + listPlatforms := flag.Bool("list-platforms", false, "Print registered cc-connect platforms as JSON.") + flag.Parse() + + if *listPlatforms { + if err := json.NewEncoder(os.Stdout).Encode(platforms.Available()); err != nil { + exit(err) + } + return + } + if *configPath == "" { + exit(fmt.Errorf("-config is required")) + } + var config gateway.Config + var err error + if *configPath == "-" { + config, err = gateway.DecodeConfig(os.Stdin) + } else { + config, err = gateway.LoadConfig(*configPath) + } + if err != nil { + exit(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := gateway.Run(ctx, config); err != nil { + exit(err) + } +} + +func exit(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/packages/remote-bridge/go.mod b/packages/remote-bridge/go.mod new file mode 100644 index 000000000..e8da4a211 --- /dev/null +++ b/packages/remote-bridge/go.mod @@ -0,0 +1,20 @@ +module github.com/astro-han/pawwork/packages/remote-bridge + +go 1.25.0 + +require github.com/chenhg5/cc-connect v1.3.3-beta.4.0.20260610161423-c53f5450ee1f + +require ( + github.com/bwmarrin/discordgo v0.29.0 // indirect + github.com/go-telegram/bot v1.20.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/larksuite/oapi-sdk-go/v3 v3.5.3 // indirect + github.com/line/line-bot-sdk-go/v8 v8.19.0 // indirect + github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/slack-go/slack v0.16.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/packages/remote-bridge/go.sum b/packages/remote-bridge/go.sum new file mode 100644 index 000000000..f563c5228 --- /dev/null +++ b/packages/remote-bridge/go.sum @@ -0,0 +1,73 @@ +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/chenhg5/cc-connect v1.3.3-beta.4.0.20260610161423-c53f5450ee1f h1:zSCV0WwVmoanPV13rUc7DabL8sv4uinuec8uOHbWDOk= +github.com/chenhg5/cc-connect v1.3.3-beta.4.0.20260610161423-c53f5450ee1f/go.mod h1:f5u37GVVMYQ2JW0GGtj/STgcjcAcZ+HwqVQJMdOvyOw= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= +github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs= +github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= +github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= +github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go new file mode 100644 index 000000000..0fb714c22 --- /dev/null +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -0,0 +1,811 @@ +package bridge + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/chenhg5/cc-connect/core" +) + +type Prompt struct { + Text string +} + +type Session struct { + ID string + Title string + ParentID string + Directory string +} + +type PendingPermission struct { + ID string + SessionID string + Permission string + Patterns []string + Directory string +} + +type PermissionReply struct { + Reply string + Message string +} + +type PendingQuestion struct { + SessionID string + MessageID string + CallID string + Questions []Question + Directory string +} + +type PermissionResolution struct { + SessionID string + RequestID string + Directory string +} + +type QuestionResolution struct { + SessionID string + MessageID string + CallID string + Directory string +} + +type Question struct { + Header string + Question string + Options []QuestionOption + Multiple bool +} + +type QuestionOption struct { + Label string + Description string +} + +type Sidecar interface { + CreateSession(context.Context) (string, error) + SendPrompt(context.Context, string, Prompt) error + ListSessions(context.Context, int) ([]Session, error) + AbortSession(context.Context, string) (bool, error) + ReplyPermission(context.Context, PendingPermission, PermissionReply) error + SubmitQuestion(context.Context, PendingQuestion, [][]string) error +} + +type SessionPointers interface { + Get(remoteKey string) string + Set(remoteKey string, sessionID string) error + SetParent(sessionID string, parentID string) error + RemoteKeyForSession(sessionID string) string + RootSession(sessionID string) string +} + +type EventCursorStore interface { + EventCursor() string + SetEventCursor(cursor string) error +} + +type Engine struct { + mu sync.Mutex + sidecar Sidecar + pointers SessionPointers + pickers map[string][]Session + active map[string]delivery + platforms map[string]core.Platform + permissions map[string]PendingPermission + permissionOrder []string + questions map[string]PendingQuestion + questionOrder []string + blockerOrder []blockerRef +} + +type blockerKind string + +const ( + permissionBlocker blockerKind = "permission" + questionBlocker blockerKind = "question" +) + +type blockerRef struct { + kind blockerKind + key string +} + +type pendingBlocker struct { + kind blockerKind + permission PendingPermission + question PendingQuestion +} + +type delivery struct { + platform core.Platform + replyCtx any + proactive bool +} + +func New(sidecar Sidecar) *Engine { + return NewWithSessionPointers(sidecar, NewMemorySessionPointers()) +} + +func NewWithSessionPointers(sidecar Sidecar, pointers SessionPointers) *Engine { + if pointers == nil { + pointers = NewMemorySessionPointers() + } + return &Engine{ + sidecar: sidecar, + pointers: pointers, + pickers: make(map[string][]Session), + active: make(map[string]delivery), + platforms: make(map[string]core.Platform), + permissions: make(map[string]PendingPermission), + questions: make(map[string]PendingQuestion), + } +} + +func (e *Engine) CurrentSession(remoteKey string) string { + return e.pointers.Get(remoteKey) +} + +func (e *Engine) RegisterPlatform(platform core.Platform) { + if platform == nil || strings.TrimSpace(platform.Name()) == "" { + return + } + e.mu.Lock() + defer e.mu.Unlock() + e.platforms[platform.Name()] = platform +} + +func (e *Engine) RegisterSession(session Session) error { + if session.ID == "" || session.ParentID == "" { + return nil + } + return e.pointers.SetParent(session.ID, session.ParentID) +} + +func (e *Engine) SetPendingPermission(permission PendingPermission) { + if permission.SessionID == "" { + return + } + key := permissionKey(permission) + e.mu.Lock() + defer e.mu.Unlock() + if _, ok := e.permissions[key]; !ok { + e.permissionOrder = append(e.permissionOrder, key) + e.blockerOrder = append(e.blockerOrder, blockerRef{kind: permissionBlocker, key: key}) + } + e.permissions[key] = permission +} + +func (e *Engine) SetPendingQuestion(question PendingQuestion) { + if question.SessionID == "" { + return + } + key := questionKey(question) + e.mu.Lock() + defer e.mu.Unlock() + if _, ok := e.questions[key]; !ok { + e.questionOrder = append(e.questionOrder, key) + e.blockerOrder = append(e.blockerOrder, blockerRef{kind: questionBlocker, key: key}) + } + e.questions[key] = question +} + +func (e *Engine) HandleMessage(ctx context.Context, platform core.Platform, msg *core.Message) error { + text := strings.TrimSpace(msg.Content) + if text == "" { + return nil + } + key := remoteKey(platform, msg) + if isRemoteCommand(text) { + return e.handleCommand(ctx, platform, msg, key, text) + } + sessionID, err := e.ensureSession(ctx, key) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not start a session: "+err.Error()) + return err + } + if handled, err := e.handlePendingReply(ctx, platform, msg, sessionID, text); handled || err != nil { + return err + } + if err := e.sidecar.SendPrompt(ctx, sessionID, Prompt{Text: text}); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not send the message: "+err.Error()) + return err + } + e.setActive(sessionID, platform, msg.ReplyCtx) + return nil +} + +func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text string) error { + if text == "" { + return nil + } + target, ok := e.activeDelivery(sessionID) + if !ok { + return nil + } + return sendDelivery(ctx, target, text) +} + +func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { + e.SetPendingPermission(permission) + return e.replyToActive(ctx, permission.SessionID, permissionPrompt(permission)) +} + +func (e *Engine) HandleQuestion(ctx context.Context, question PendingQuestion) error { + e.SetPendingQuestion(question) + return e.replyToActive(ctx, question.SessionID, questionPrompt(question)) +} + +func (e *Engine) HandlePermissionResolved(_ context.Context, resolution PermissionResolution) error { + e.clearResolvedPermission(resolution) + return nil +} + +func (e *Engine) HandleQuestionResolved(_ context.Context, resolution QuestionResolution) error { + e.clearResolvedQuestion(resolution) + return nil +} + +func (e *Engine) HandleSession(_ context.Context, session Session) error { + return e.RegisterSession(session) +} + +func (e *Engine) replyToActive(ctx context.Context, sessionID string, content string) error { + target, ok := e.activeDelivery(sessionID) + if !ok { + return nil + } + return sendDelivery(ctx, target, content) +} + +func (e *Engine) setActive(sessionID string, platform core.Platform, replyCtx any) { + e.mu.Lock() + defer e.mu.Unlock() + e.active[sessionID] = delivery{platform: platform, replyCtx: replyCtx} +} + +func (e *Engine) activeDelivery(sessionID string) (delivery, bool) { + e.mu.Lock() + target, ok := e.active[sessionID] + if !ok { + target, ok = e.active[e.pointers.RootSession(sessionID)] + } + e.mu.Unlock() + if ok { + return target, true + } + return e.restoreDelivery(sessionID) +} + +func (e *Engine) restoreDelivery(sessionID string) (delivery, bool) { + remoteKey := e.pointers.RemoteKeyForSession(sessionID) + platformName, _, ok := strings.Cut(remoteKey, ":") + if !ok || platformName == "" { + return delivery{}, false + } + + e.mu.Lock() + platform := e.platforms[platformName] + e.mu.Unlock() + reconstructor, ok := platform.(core.ReplyContextReconstructor) + if !ok { + return delivery{}, false + } + replyCtx, err := reconstructor.ReconstructReplyCtx(remoteKey) + if err != nil { + return delivery{}, false + } + target := delivery{platform: platform, replyCtx: replyCtx, proactive: true} + + e.mu.Lock() + defer e.mu.Unlock() + if current, ok := e.active[sessionID]; ok { + return current, true + } + e.active[sessionID] = target + return target, true +} + +func sendDelivery(ctx context.Context, target delivery, content string) error { + if target.proactive { + return target.platform.Send(ctx, target.replyCtx, content) + } + return target.platform.Reply(ctx, target.replyCtx, content) +} + +func (e *Engine) handlePendingReply( + ctx context.Context, + platform core.Platform, + msg *core.Message, + sessionID string, + text string, +) (bool, error) { + blocker, ok := e.pendingBlocker(sessionID) + if !ok { + return false, nil + } + if blocker.kind == permissionBlocker { + permission := blocker.permission + reply := permissionReplyForText(text) + if reply == "" { + return true, platform.Reply(ctx, msg.ReplyCtx, "Reply yes, always, or no.") + } + if err := e.sidecar.ReplyPermission(ctx, permission, PermissionReply{Reply: reply}); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not answer the permission request: "+err.Error()) + return true, err + } + e.clearPendingPermission(permission) + return true, nil + } + if blocker.kind == questionBlocker { + question := blocker.question + answers, err := answersForQuestionText(question, text) + if err != nil { + return true, platform.Reply(ctx, msg.ReplyCtx, err.Error()) + } + if err := e.sidecar.SubmitQuestion(ctx, question, answers); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not submit the answer: "+err.Error()) + return true, err + } + e.clearPendingQuestion(question) + return true, nil + } + return false, nil +} + +func isRemoteCommand(text string) bool { + name, _, _ := strings.Cut(text, " ") + switch name { + case "/new", "/sessions", "/stop", "/help": + return true + default: + return false + } +} + +func (e *Engine) pendingBlocker(sessionID string) (pendingBlocker, bool) { + e.mu.Lock() + defer e.mu.Unlock() + root := e.pointers.RootSession(sessionID) + for index := len(e.blockerOrder) - 1; index >= 0; index-- { + ref := e.blockerOrder[index] + switch ref.kind { + case permissionBlocker: + permission, ok := e.permissions[ref.key] + if !ok || e.pointers.RootSession(permission.SessionID) != root { + continue + } + if pending, ok := e.pendingPermissionLocked(root); ok { + return pendingBlocker{kind: permissionBlocker, permission: pending}, true + } + case questionBlocker: + question, ok := e.questions[ref.key] + if !ok || e.pointers.RootSession(question.SessionID) != root { + continue + } + if pending, ok := e.pendingQuestionLocked(root); ok { + return pendingBlocker{kind: questionBlocker, question: pending}, true + } + } + } + return pendingBlocker{}, false +} + +func (e *Engine) pendingPermissionLocked(root string) (PendingPermission, bool) { + for _, key := range e.permissionOrder { + permission, ok := e.permissions[key] + if !ok { + continue + } + if e.pointers.RootSession(permission.SessionID) == root { + return permission, true + } + } + return PendingPermission{}, false +} + +func (e *Engine) clearPendingPermission(permission PendingPermission) { + e.mu.Lock() + defer e.mu.Unlock() + e.clearPermissionKeyLocked(permissionKey(permission)) +} + +func (e *Engine) clearResolvedPermission(resolution PermissionResolution) { + e.mu.Lock() + defer e.mu.Unlock() + if resolution.RequestID != "" { + e.clearPermissionKeyLocked(resolution.RequestID) + return + } + if resolution.SessionID != "" { + e.clearPermissionsLocked(func(permission PendingPermission) bool { + return permission.SessionID == resolution.SessionID + }) + } +} + +func (e *Engine) clearPermissionKeyLocked(key string) { + delete(e.permissions, key) + e.clearPermissionOrderLocked(func(current string) bool { + return current == key + }) + e.clearBlockerOrderLocked(func(current blockerRef) bool { + return current.kind == permissionBlocker && current.key == key + }) +} + +func (e *Engine) clearPermissionsLocked(match func(PendingPermission) bool) { + for key, permission := range e.permissions { + if match(permission) { + delete(e.permissions, key) + } + } + e.clearPermissionOrderLocked(func(key string) bool { + _, ok := e.permissions[key] + return !ok + }) + e.clearBlockerOrderLocked(func(current blockerRef) bool { + if current.kind != permissionBlocker { + return false + } + _, ok := e.permissions[current.key] + return !ok + }) +} + +func (e *Engine) clearPermissionOrderLocked(match func(string) bool) { + next := e.permissionOrder[:0] + for _, key := range e.permissionOrder { + if !match(key) { + next = append(next, key) + } + } + e.permissionOrder = next +} + +func (e *Engine) pendingQuestionLocked(root string) (PendingQuestion, bool) { + for _, key := range e.questionOrder { + question, ok := e.questions[key] + if !ok { + continue + } + if e.pointers.RootSession(question.SessionID) == root { + return question, true + } + } + return PendingQuestion{}, false +} + +func (e *Engine) clearPendingQuestion(question PendingQuestion) { + e.mu.Lock() + defer e.mu.Unlock() + e.clearQuestionKeyLocked(questionKey(question)) +} + +func (e *Engine) clearResolvedQuestion(resolution QuestionResolution) { + e.mu.Lock() + defer e.mu.Unlock() + if resolution.CallID != "" || resolution.MessageID != "" { + for key, question := range e.questions { + callMatches := resolution.CallID == "" || question.CallID == resolution.CallID + messageMatches := resolution.MessageID == "" || question.MessageID == resolution.MessageID + if callMatches && messageMatches { + e.clearQuestionKeyLocked(key) + return + } + } + return + } + if resolution.SessionID != "" { + e.clearQuestionsLocked(func(question PendingQuestion) bool { + return question.SessionID == resolution.SessionID + }) + } +} + +func (e *Engine) clearQuestionKeyLocked(key string) { + delete(e.questions, key) + e.clearQuestionOrderLocked(func(current string) bool { + return current == key + }) + e.clearBlockerOrderLocked(func(current blockerRef) bool { + return current.kind == questionBlocker && current.key == key + }) +} + +func (e *Engine) clearQuestionsLocked(match func(PendingQuestion) bool) { + for key, question := range e.questions { + if match(question) { + delete(e.questions, key) + } + } + e.clearQuestionOrderLocked(func(key string) bool { + _, ok := e.questions[key] + return !ok + }) + e.clearBlockerOrderLocked(func(current blockerRef) bool { + if current.kind != questionBlocker { + return false + } + _, ok := e.questions[current.key] + return !ok + }) +} + +func (e *Engine) clearQuestionOrderLocked(match func(string) bool) { + next := e.questionOrder[:0] + for _, key := range e.questionOrder { + if !match(key) { + next = append(next, key) + } + } + e.questionOrder = next +} + +func (e *Engine) clearBlockerOrderLocked(match func(blockerRef) bool) { + next := e.blockerOrder[:0] + for _, current := range e.blockerOrder { + if !match(current) { + next = append(next, current) + } + } + e.blockerOrder = next +} + +func (e *Engine) handleCommand(ctx context.Context, platform core.Platform, msg *core.Message, key string, text string) error { + name, arg, _ := strings.Cut(text, " ") + switch name { + case "/new": + sessionID, err := e.sidecar.CreateSession(ctx) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not start a session: "+err.Error()) + return err + } + if err := e.setCurrent(key, sessionID); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not remember the session: "+err.Error()) + return err + } + e.setActive(sessionID, platform, msg.ReplyCtx) + return platform.Reply(ctx, msg.ReplyCtx, "Started a new PawWork session.") + case "/sessions": + arg = strings.TrimSpace(arg) + if arg == "" { + return e.replySessionPicker(ctx, platform, msg, key) + } + return e.switchSession(ctx, platform, msg, key, arg) + case "/stop": + sessionID := e.CurrentSession(key) + if sessionID == "" { + return platform.Reply(ctx, msg.ReplyCtx, "No active PawWork session.") + } + aborted, err := e.sidecar.AbortSession(ctx, sessionID) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not stop the run: "+err.Error()) + return err + } + if aborted { + return platform.Reply(ctx, msg.ReplyCtx, "Stopped the current PawWork run.") + } + return platform.Reply(ctx, msg.ReplyCtx, "No running PawWork run.") + case "/help": + return platform.Reply(ctx, msg.ReplyCtx, "Commands: /new, /sessions, /sessions N, /stop.") + default: + return platform.Reply(ctx, msg.ReplyCtx, "Unknown command. Try /help.") + } +} + +func (e *Engine) ensureSession(ctx context.Context, key string) (string, error) { + if sessionID := e.CurrentSession(key); sessionID != "" { + return sessionID, nil + } + sessionID, err := e.sidecar.CreateSession(ctx) + if err != nil { + return "", err + } + return sessionID, e.setCurrent(key, sessionID) +} + +func (e *Engine) replySessionPicker(ctx context.Context, platform core.Platform, msg *core.Message, key string) error { + sessions, err := e.sidecar.ListSessions(ctx, 5) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not list sessions: "+err.Error()) + return err + } + if len(sessions) == 0 { + e.clearPicker(key) + return platform.Reply(ctx, msg.ReplyCtx, "No recent PawWork sessions.") + } + e.setPicker(key, sessions) + var out strings.Builder + out.WriteString("Recent PawWork sessions:") + for index, session := range sessions { + out.WriteString("\n") + out.WriteString(strconv.Itoa(index + 1)) + out.WriteString(". ") + out.WriteString(sessionLabel(session)) + } + out.WriteString("\n\nSwitch with /sessions 2.") + return platform.Reply(ctx, msg.ReplyCtx, out.String()) +} + +func (e *Engine) switchSession(ctx context.Context, platform core.Platform, msg *core.Message, key string, rawIndex string) error { + index, err := strconv.Atoi(rawIndex) + if err != nil || index < 1 { + return platform.Reply(ctx, msg.ReplyCtx, "Choose a session with /sessions 1.") + } + sessions := e.picker(key) + if len(sessions) == 0 { + sessions, err = e.sidecar.ListSessions(ctx, 5) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not list sessions: "+err.Error()) + return err + } + } + if index > len(sessions) { + return platform.Reply(ctx, msg.ReplyCtx, fmt.Sprintf("Only %d recent PawWork sessions are available.", len(sessions))) + } + session := sessions[index-1] + if err := e.RegisterSession(session); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not remember the session: "+err.Error()) + return err + } + if err := e.setCurrent(key, session.ID); err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not remember the session: "+err.Error()) + return err + } + e.setActive(session.ID, platform, msg.ReplyCtx) + return platform.Reply(ctx, msg.ReplyCtx, "Switched to "+sessionLabel(session)+".") +} + +func (e *Engine) picker(remoteKey string) []Session { + e.mu.Lock() + defer e.mu.Unlock() + return e.pickers[remoteKey] +} + +func (e *Engine) setPicker(remoteKey string, sessions []Session) { + e.mu.Lock() + defer e.mu.Unlock() + e.pickers[remoteKey] = sessions +} + +func (e *Engine) clearPicker(remoteKey string) { + e.mu.Lock() + defer e.mu.Unlock() + delete(e.pickers, remoteKey) +} + +func (e *Engine) setCurrent(remoteKey string, sessionID string) error { + return e.pointers.Set(remoteKey, sessionID) +} + +func sessionLabel(session Session) string { + if strings.TrimSpace(session.Title) != "" { + return session.Title + } + return session.ID +} + +func permissionPrompt(permission PendingPermission) string { + var out strings.Builder + out.WriteString("PawWork asks permission: ") + out.WriteString(permission.Permission) + for _, pattern := range permission.Patterns { + if strings.TrimSpace(pattern) == "" { + continue + } + out.WriteString("\n") + out.WriteString(pattern) + } + out.WriteString("\n\nReply yes, always, or no.") + return out.String() +} + +func questionPrompt(pending PendingQuestion) string { + if len(pending.Questions) == 0 { + return "PawWork asks a question.\n\nReply with your answer." + } + var out strings.Builder + for index, question := range pending.Questions { + if index > 0 { + out.WriteString("\n\n") + } + if strings.TrimSpace(question.Header) != "" { + out.WriteString(question.Header) + out.WriteString("\n") + } + out.WriteString(question.Question) + for optionIndex, option := range question.Options { + out.WriteString("\n") + out.WriteString(strconv.Itoa(optionIndex + 1)) + out.WriteString(". ") + out.WriteString(option.Label) + if strings.TrimSpace(option.Description) != "" { + out.WriteString(" - ") + out.WriteString(option.Description) + } + } + } + out.WriteString("\n\nReply with a number or answer text.") + return out.String() +} + +func permissionReplyForText(text string) string { + switch strings.ToLower(strings.TrimSpace(text)) { + case "yes", "y", "allow", "ok": + return "once" + case "always", "always allow": + return "always" + case "no", "n", "deny", "reject": + return "reject" + default: + return "" + } +} + +func answersForQuestionText(pending PendingQuestion, text string) ([][]string, error) { + questions := pending.Questions + if len(questions) == 0 { + return [][]string{{text}}, nil + } + if len(questions) == 1 { + return [][]string{answerRowForQuestion(text, questions[0])}, nil + } + lines := strings.Split(strings.TrimSpace(text), "\n") + if len(lines) != len(questions) { + return nil, fmt.Errorf("Reply with %d lines, one answer per question.", len(questions)) + } + answers := make([][]string, 0, len(questions)) + for index, line := range lines { + answers = append(answers, answerRowForQuestion(line, questions[index])) + } + return answers, nil +} + +func answerRowForQuestion(text string, question Question) []string { + text = strings.TrimSpace(text) + if !question.Multiple { + return []string{answerTokenForQuestion(text, question)} + } + parts := strings.Split(text, ",") + answers := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + answers = append(answers, answerTokenForQuestion(trimmed, question)) + } + } + return answers +} + +func answerTokenForQuestion(text string, question Question) string { + if index, err := strconv.Atoi(text); err == nil && index >= 1 && index <= len(question.Options) { + return question.Options[index-1].Label + } + return text +} + +func permissionKey(permission PendingPermission) string { + if permission.ID != "" { + return permission.ID + } + return permission.SessionID +} + +func questionKey(question PendingQuestion) string { + if question.MessageID != "" || question.CallID != "" { + return question.MessageID + "\x00" + question.CallID + } + return question.SessionID +} + +func remoteKey(platform core.Platform, msg *core.Message) string { + if strings.TrimSpace(msg.SessionKey) != "" { + return msg.SessionKey + } + parts := []string{platform.Name(), msg.ChannelID, msg.UserID} + return strings.Join(parts, ":") +} diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go new file mode 100644 index 000000000..9caee0952 --- /dev/null +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -0,0 +1,803 @@ +package bridge + +import ( + "context" + "path/filepath" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +type fakeSidecar struct { + created []string + prompts []sentPrompt + sessions []Session + permissionReplies []permissionReply + questionReplies []questionReply + aborted bool +} + +type sentPrompt struct { + sessionID string + text string +} + +type permissionReply struct { + pending PendingPermission + reply PermissionReply +} + +type questionReply struct { + pending PendingQuestion + answers [][]string +} + +func (f *fakeSidecar) CreateSession(context.Context) (string, error) { + id := "ses_new" + if len(f.created) > 0 { + id = "ses_new_2" + } + f.created = append(f.created, id) + return id, nil +} + +func (f *fakeSidecar) SendPrompt(_ context.Context, sessionID string, prompt Prompt) error { + f.prompts = append(f.prompts, sentPrompt{sessionID: sessionID, text: prompt.Text}) + return nil +} + +func (f *fakeSidecar) ListSessions(context.Context, int) ([]Session, error) { + return f.sessions, nil +} + +func (f *fakeSidecar) AbortSession(context.Context, string) (bool, error) { + return f.aborted, nil +} + +func (f *fakeSidecar) ReplyPermission(_ context.Context, pending PendingPermission, reply PermissionReply) error { + f.permissionReplies = append(f.permissionReplies, permissionReply{pending: pending, reply: reply}) + return nil +} + +func (f *fakeSidecar) SubmitQuestion(_ context.Context, pending PendingQuestion, answers [][]string) error { + f.questionReplies = append(f.questionReplies, questionReply{pending: pending, answers: answers}) + return nil +} + +type fakePlatform struct { + name string + replies []string + sends []string + reconstructKey string +} + +func (f *fakePlatform) Name() string { + if f.name != "" { + return f.name + } + return "chat" +} +func (f *fakePlatform) Start(core.MessageHandler) error { return nil } +func (f *fakePlatform) Reply(_ context.Context, _ any, content string) error { + f.replies = append(f.replies, content) + return nil +} +func (f *fakePlatform) Send(_ context.Context, _ any, content string) error { + f.sends = append(f.sends, content) + return nil +} +func (f *fakePlatform) ReconstructReplyCtx(sessionKey string) (any, error) { + f.reconstructKey = sessionKey + return "restored-reply-context", nil +} +func (f *fakePlatform) Stop() error { return nil } + +func TestEngineStartsAndContinuesCurrentSession(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + + msg := &core.Message{SessionKey: "feishu:chat:alice", Content: "/new"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.created) != 1 { + t.Fatalf("created sessions = %#v", sidecar.created) + } + + msg.Content = "continue this" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.prompts) != 1 { + t.Fatalf("prompts = %#v", sidecar.prompts) + } + if sidecar.prompts[0] != (sentPrompt{sessionID: "ses_new", text: "continue this"}) { + t.Fatalf("prompt = %#v", sidecar.prompts[0]) + } +} + +func TestEngineRepliesToNewSessionCommandEvents(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "feishu:chat:alice", Content: "/new", ReplyCtx: "reply-ctx"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if err := engine.HandleAssistantText(context.Background(), "ses_new", "new session is ready"); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "new session is ready" { + t.Fatalf("reply = %q, replies = %#v", got, platform.replies) + } +} + +func TestEngineSendsUnknownSlashTextAsPrompt(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "/src/main.go"} + + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.prompts) != 1 || sidecar.prompts[0] != (sentPrompt{sessionID: "ses_new", text: "/src/main.go"}) { + t.Fatalf("prompts = %#v", sidecar.prompts) + } + if len(platform.replies) != 0 { + t.Fatalf("unknown slash text was treated as command: %#v", platform.replies) + } +} + +func TestEngineListsAndSwitchesRecentSessions(t *testing.T) { + sidecar := &fakeSidecar{ + sessions: []Session{ + {ID: "ses_a", Title: "Plan launch"}, + {ID: "ses_b", Title: "Fix importer"}, + }, + } + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:bob", Content: "/sessions"} + + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(platform.replies) != 1 || platform.replies[0] != "Recent PawWork sessions:\n1. Plan launch\n2. Fix importer\n\nSwitch with /sessions 2." { + t.Fatalf("list reply = %#v", platform.replies) + } + + msg.Content = "/sessions 2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if got := engine.CurrentSession("slack:dm:bob"); got != "ses_b" { + t.Fatalf("current session = %q", got) + } + + msg.Content = "use this session" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if sidecar.prompts[0] != (sentPrompt{sessionID: "ses_b", text: "use this session"}) { + t.Fatalf("prompt = %#v", sidecar.prompts[0]) + } +} + +func TestEngineRejectsSwitchingToChildOfAnotherRemoteRoot(t *testing.T) { + sidecar := &fakeSidecar{ + sessions: []Session{ + {ID: "ses_root", Title: "Root"}, + {ID: "ses_child", Title: "Child", ParentID: "ses_root"}, + }, + } + engine := New(sidecar) + slack := &fakePlatform{name: "slack"} + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "/sessions"} + if err := engine.HandleMessage(context.Background(), slack, msg); err != nil { + t.Fatal(err) + } + msg.Content = "/sessions 1" + if err := engine.HandleMessage(context.Background(), slack, msg); err != nil { + t.Fatal(err) + } + + feishu := &fakePlatform{name: "feishu"} + msg = &core.Message{SessionKey: "feishu:chat:ops", Content: "/sessions"} + if err := engine.HandleMessage(context.Background(), feishu, msg); err != nil { + t.Fatal(err) + } + msg.Content = "/sessions 2" + if err := engine.HandleMessage(context.Background(), feishu, msg); err == nil { + t.Fatal("expected switching to a child of another remote root to fail") + } + + if got := engine.CurrentSession("feishu:chat:ops"); got != "" { + t.Fatalf("feishu current session = %q", got) + } + if got := feishu.replies[len(feishu.replies)-1]; got != "PawWork could not remember the session: session root is already bound to another remote conversation" { + t.Fatalf("reply = %q", got) + } +} + +func TestEngineRepliesToSwitchedSessionEvents(t *testing.T) { + sidecar := &fakeSidecar{ + sessions: []Session{ + {ID: "ses_a", Title: "Plan launch"}, + {ID: "ses_b", Title: "Fix importer"}, + }, + } + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:bob", Content: "/sessions"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + msg.Content = "/sessions 2" + msg.ReplyCtx = "switch-reply-ctx" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if err := engine.HandleAssistantText(context.Background(), "ses_b", "switched session completed"); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "switched session completed" { + t.Fatalf("reply = %q, replies = %#v", got, platform.replies) + } +} + +func TestEngineRoutesPendingPermissionRepliesBeforePrompts(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "weixin:user:alice", Content: "/new"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + engine.SetPendingPermission(PendingPermission{ + ID: "perm_1", + SessionID: "ses_new", + Permission: "edit", + Patterns: []string{"/repo/app.ts"}, + }) + + msg.Content = "yes" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.permissionReplies) != 1 { + t.Fatalf("permission replies = %#v", sidecar.permissionReplies) + } + gotPermission := sidecar.permissionReplies[0] + if gotPermission.pending.ID != "perm_1" || + gotPermission.pending.SessionID != "ses_new" || + gotPermission.pending.Permission != "edit" || + len(gotPermission.pending.Patterns) != 1 || + gotPermission.pending.Patterns[0] != "/repo/app.ts" || + gotPermission.reply != (PermissionReply{Reply: "once"}) { + t.Fatalf("permission reply = %#v", sidecar.permissionReplies[0]) + } + if len(sidecar.prompts) != 0 { + t.Fatalf("permission answer became prompt: %#v", sidecar.prompts) + } +} + +func TestEngineRoutesPendingQuestionAnswersBeforePrompts(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "/new"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + pending := PendingQuestion{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{ + Question: "Pick one", + Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}, + } + engine.SetPendingQuestion(pending) + + msg.Content = "2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.questionReplies) != 1 { + t.Fatalf("question replies = %#v", sidecar.questionReplies) + } + if sidecar.questionReplies[0].pending.SessionID != pending.SessionID || + sidecar.questionReplies[0].pending.MessageID != pending.MessageID || + sidecar.questionReplies[0].pending.CallID != pending.CallID { + t.Fatalf("pending = %#v", sidecar.questionReplies[0].pending) + } + if got := sidecar.questionReplies[0].answers; len(got) != 1 || len(got[0]) != 1 || got[0][0] != "B" { + t.Fatalf("answers = %#v", got) + } + if len(sidecar.prompts) != 0 { + t.Fatalf("question answer became prompt: %#v", sidecar.prompts) + } +} + +func TestEngineStopsCurrentRun(t *testing.T) { + sidecar := &fakeSidecar{aborted: true} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "telegram:alice", Content: "/new"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + msg.Content = "/stop" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "Stopped the current PawWork run." { + t.Fatalf("stop reply = %q", got) + } +} + +func TestEnginePersistsCurrentSessionPointer(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + store, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + + platform := &fakePlatform{} + engine := NewWithSessionPointers(&fakeSidecar{}, store) + msg := &core.Message{SessionKey: "feishu:chat:alice", Content: "/new"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + reloaded, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + sidecar := &fakeSidecar{} + engine = NewWithSessionPointers(sidecar, reloaded) + msg.Content = "continue here" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.created) != 0 { + t.Fatalf("created sessions = %#v", sidecar.created) + } + if len(sidecar.prompts) != 1 || sidecar.prompts[0] != (sentPrompt{sessionID: "ses_new", text: "continue here"}) { + t.Fatalf("prompts = %#v", sidecar.prompts) + } +} + +func TestEngineRepliesToActiveConversationEvents(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "what changed?"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if err := engine.HandleAssistantText(context.Background(), "ses_new", "A small fix landed."); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "A small fix landed." { + t.Fatalf("reply = %q", got) + } +} + +func TestEngineRestoresReplyTargetAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + store, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + + firstPlatform := &fakePlatform{name: "slack"} + firstEngine := NewWithSessionPointers(&fakeSidecar{}, store) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start work"} + if err := firstEngine.HandleMessage(context.Background(), firstPlatform, msg); err != nil { + t.Fatal(err) + } + + reloaded, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + secondPlatform := &fakePlatform{name: "slack"} + secondEngine := NewWithSessionPointers(&fakeSidecar{}, reloaded) + secondEngine.RegisterPlatform(secondPlatform) + if err := secondEngine.HandleAssistantText(context.Background(), "ses_new", "finished after restart"); err != nil { + t.Fatal(err) + } + + if secondPlatform.reconstructKey != "slack:dm:alice" { + t.Fatalf("reconstruct key = %q", secondPlatform.reconstructKey) + } + if len(secondPlatform.sends) != 1 || secondPlatform.sends[0] != "finished after restart" { + t.Fatalf("sends = %#v", secondPlatform.sends) + } + if len(secondPlatform.replies) != 0 { + t.Fatalf("restored target used Reply: %#v", secondPlatform.replies) + } +} + +func TestEngineSurfacesPendingPermissionEvents(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "weixin:user:alice", Content: "edit the file"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if err := engine.HandlePermission(context.Background(), PendingPermission{ + ID: "perm_1", + SessionID: "ses_new", + Permission: "edit", + Patterns: []string{"/repo/app.ts"}, + }); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "PawWork asks permission: edit\n/repo/app.ts\n\nReply yes, always, or no." { + t.Fatalf("permission prompt = %q", got) + } + + msg.Content = "always" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if sidecar.permissionReplies[0].reply.Reply != "always" { + t.Fatalf("permission reply = %#v", sidecar.permissionReplies) + } +} + +func TestEngineRoutesChildSessionPermissionThroughRootConversation(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "delegate this"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if err := engine.RegisterSession(Session{ID: "child_1", ParentID: "ses_new"}); err != nil { + t.Fatal(err) + } + + if err := engine.HandlePermission(context.Background(), PendingPermission{ + ID: "perm_child", + SessionID: "child_1", + Permission: "edit", + Patterns: []string{"/repo/child.ts"}, + }); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "PawWork asks permission: edit\n/repo/child.ts\n\nReply yes, always, or no." { + t.Fatalf("permission prompt = %q", got) + } + + msg.Content = "yes" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.permissionReplies) != 1 || sidecar.permissionReplies[0].pending.ID != "perm_child" { + t.Fatalf("permission replies = %#v", sidecar.permissionReplies) + } + if len(sidecar.prompts) != 1 { + t.Fatalf("permission answer became prompt: %#v", sidecar.prompts) + } + + msg.Content = "continue after permission" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.prompts) != 2 || sidecar.prompts[1] != (sentPrompt{sessionID: "ses_new", text: "continue after permission"}) { + t.Fatalf("next prompt after permission = %#v", sidecar.prompts) + } +} + +func TestEngineAnswersPendingPermissionsInArrivalOrder(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + for _, permission := range []PendingPermission{ + {ID: "perm_first", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/a.ts"}}, + {ID: "perm_second", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/b.ts"}}, + } { + if err := engine.HandlePermission(context.Background(), permission); err != nil { + t.Fatal(err) + } + } + + msg.Content = "yes" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + msg.Content = "no" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.permissionReplies) != 2 { + t.Fatalf("permission replies = %#v", sidecar.permissionReplies) + } + if sidecar.permissionReplies[0].pending.ID != "perm_first" || sidecar.permissionReplies[1].pending.ID != "perm_second" { + t.Fatalf("permission reply order = %#v", sidecar.permissionReplies) + } +} + +func TestEngineRestoresChildSessionDeliveryAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + store, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + firstEngine := NewWithSessionPointers(&fakeSidecar{}, store) + if err := firstEngine.setCurrent("slack:dm:alice", "ses_root"); err != nil { + t.Fatal(err) + } + if err := firstEngine.RegisterSession(Session{ID: "ses_child", ParentID: "ses_root"}); err != nil { + t.Fatal(err) + } + + reloaded, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + platform := &fakePlatform{name: "slack"} + secondEngine := NewWithSessionPointers(&fakeSidecar{}, reloaded) + secondEngine.RegisterPlatform(platform) + if err := secondEngine.HandleAssistantText(context.Background(), "ses_child", "child completed"); err != nil { + t.Fatal(err) + } + + if platform.reconstructKey != "slack:dm:alice" { + t.Fatalf("reconstruct key = %q", platform.reconstructKey) + } + if len(platform.sends) != 1 || platform.sends[0] != "child completed" { + t.Fatalf("sends = %#v", platform.sends) + } +} + +func TestEngineSurfacesPendingQuestionEvents(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "feishu:chat:alice", Content: "plan it"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + pending := PendingQuestion{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{ + Header: "Approach", + Question: "Which path should I take?", + Options: []QuestionOption{ + {Label: "A", Description: "Small change"}, + {Label: "B", Description: "Larger cleanup"}, + }, + }}, + } + if err := engine.HandleQuestion(context.Background(), pending); err != nil { + t.Fatal(err) + } + + if got := platform.replies[len(platform.replies)-1]; got != "Approach\nWhich path should I take?\n1. A - Small change\n2. B - Larger cleanup\n\nReply with a number or answer text." { + t.Fatalf("question prompt = %q", got) + } +} + +func TestEngineMapsMultiSelectNumbersToOptionLabels(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "feishu:chat:alice", Content: "choose"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if err := engine.HandleQuestion(context.Background(), PendingQuestion{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{ + Question: "Pick several", + Multiple: true, + Options: []QuestionOption{{Label: "A"}, {Label: "B"}, {Label: "C"}}, + }}, + }); err != nil { + t.Fatal(err) + } + + msg.Content = "1, 3" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.questionReplies) != 1 { + t.Fatalf("question replies = %#v", sidecar.questionReplies) + } + if got := sidecar.questionReplies[0].answers; len(got) != 1 || len(got[0]) != 2 || got[0][0] != "A" || got[0][1] != "C" { + t.Fatalf("answers = %#v", got) + } +} + +func TestEngineAnswersPendingQuestionsInArrivalOrder(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + for _, question := range []PendingQuestion{ + { + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{Question: "First?", Options: []QuestionOption{{Label: "A"}, {Label: "B"}}}}, + }, + { + SessionID: "ses_new", + MessageID: "msg_2", + CallID: "call_2", + Questions: []Question{{Question: "Second?", Options: []QuestionOption{{Label: "C"}, {Label: "D"}}}}, + }, + } { + if err := engine.HandleQuestion(context.Background(), question); err != nil { + t.Fatal(err) + } + } + + msg.Content = "1" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + msg.Content = "2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.questionReplies) != 2 { + t.Fatalf("question replies = %#v", sidecar.questionReplies) + } + first := sidecar.questionReplies[0] + second := sidecar.questionReplies[1] + if first.pending.CallID != "call_1" || first.answers[0][0] != "A" { + t.Fatalf("first question reply = %#v", first) + } + if second.pending.CallID != "call_2" || second.answers[0][0] != "D" { + t.Fatalf("second question reply = %#v", second) + } +} + +func TestEngineRepliesToLatestVisibleBlocker(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if err := engine.HandlePermission(context.Background(), PendingPermission{ + ID: "perm_1", + SessionID: "ses_new", + Permission: "edit", + Patterns: []string{"/repo/app.ts"}, + }); err != nil { + t.Fatal(err) + } + if err := engine.HandleQuestion(context.Background(), PendingQuestion{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{Question: "Pick one", Options: []QuestionOption{{Label: "A"}, {Label: "B"}}}}, + }); err != nil { + t.Fatal(err) + } + + msg.Content = "2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.questionReplies) != 1 || sidecar.questionReplies[0].pending.CallID != "call_1" { + t.Fatalf("question replies = %#v", sidecar.questionReplies) + } + if len(sidecar.permissionReplies) != 0 { + t.Fatalf("permission replies = %#v", sidecar.permissionReplies) + } + if len(sidecar.prompts) != 1 { + t.Fatalf("question answer became prompt: %#v", sidecar.prompts) + } +} + +func TestEngineClearsPermissionResolvedOutsideRemote(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + engine.SetPendingPermission(PendingPermission{ + ID: "perm_1", + SessionID: "ses_new", + Permission: "edit", + Patterns: []string{"/repo/app.ts"}, + }) + + if err := engine.HandlePermissionResolved(context.Background(), PermissionResolution{ + SessionID: "ses_new", + RequestID: "perm_1", + }); err != nil { + t.Fatal(err) + } + msg.Content = "continue after desktop reply" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.prompts) != 2 || sidecar.prompts[1] != (sentPrompt{sessionID: "ses_new", text: "continue after desktop reply"}) { + t.Fatalf("prompts = %#v", sidecar.prompts) + } +} + +func TestEngineClearsQuestionResolvedOutsideRemote(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:alice", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + engine.SetPendingQuestion(PendingQuestion{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + Questions: []Question{{ + Question: "Pick one", + Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}, + }) + + if err := engine.HandleQuestionResolved(context.Background(), QuestionResolution{ + SessionID: "ses_new", + MessageID: "msg_1", + CallID: "call_1", + }); err != nil { + t.Fatal(err) + } + msg.Content = "continue after desktop answer" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + if len(sidecar.prompts) != 2 || sidecar.prompts[1] != (sentPrompt{sessionID: "ses_new", text: "continue after desktop answer"}) { + t.Fatalf("prompts = %#v", sidecar.prompts) + } +} diff --git a/packages/remote-bridge/internal/bridge/session_pointers.go b/packages/remote-bridge/internal/bridge/session_pointers.go new file mode 100644 index 000000000..1c7a8331a --- /dev/null +++ b/packages/remote-bridge/internal/bridge/session_pointers.go @@ -0,0 +1,308 @@ +package bridge + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +type MemorySessionPointers struct { + mu sync.Mutex + sessions map[string]string + parents map[string]string + eventCursor string +} + +func NewMemorySessionPointers() *MemorySessionPointers { + return &MemorySessionPointers{ + sessions: make(map[string]string), + parents: make(map[string]string), + } +} + +func (p *MemorySessionPointers) Get(remoteKey string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.sessions[remoteKey] +} + +func (p *MemorySessionPointers) Set(remoteKey string, sessionID string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.hasRootConflictLocked(remoteKey, sessionID) { + return fmt.Errorf("session root is already bound to another remote conversation") + } + p.sessions[remoteKey] = sessionID + return nil +} + +func (p *MemorySessionPointers) SetParent(sessionID string, parentID string) error { + if sessionID == "" || parentID == "" { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if hasAnyRootConflict(p.sessions, withParent(p.parents, sessionID, parentID)) { + return fmt.Errorf("session root is already bound to another remote conversation") + } + p.parents[sessionID] = parentID + return nil +} + +func (p *MemorySessionPointers) RemoteKeyForSession(sessionID string) string { + p.mu.Lock() + defer p.mu.Unlock() + keys := p.remoteKeysForRootLocked(p.rootLocked(sessionID)) + if len(keys) != 1 { + return "" + } + return keys[0] +} + +func (p *MemorySessionPointers) RootSession(sessionID string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.rootLocked(sessionID) +} + +func (p *MemorySessionPointers) EventCursor() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.eventCursor +} + +func (p *MemorySessionPointers) SetEventCursor(cursor string) error { + if cursor == "" { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + p.eventCursor = cursor + return nil +} + +func (p *MemorySessionPointers) rootLocked(sessionID string) string { + return rootSession(p.parents, sessionID) +} + +func (p *MemorySessionPointers) hasRootConflictLocked(remoteKey string, sessionID string) bool { + return hasRootConflict(p.sessions, p.parents, remoteKey, sessionID) +} + +func (p *MemorySessionPointers) remoteKeysForRootLocked(root string) []string { + return remoteKeysForRoot(p.sessions, p.parents, root) +} + +type FileSessionPointers struct { + mu sync.Mutex + path string + sessions map[string]string + parents map[string]string + eventCursor string +} + +func NewFileSessionPointers(path string) (*FileSessionPointers, error) { + pointers := &FileSessionPointers{ + path: path, + sessions: make(map[string]string), + parents: make(map[string]string), + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return pointers, nil + } + if err != nil { + return nil, err + } + if len(data) == 0 { + return pointers, nil + } + var state struct { + Sessions map[string]string `json:"sessions"` + Parents map[string]string `json:"parents"` + EventCursor string `json:"eventCursor"` + } + if err := json.Unmarshal(data, &state); err == nil && (state.Sessions != nil || state.Parents != nil || state.EventCursor != "") { + if state.Sessions != nil { + pointers.sessions = state.Sessions + } + if state.Parents != nil { + pointers.parents = state.Parents + } + pointers.eventCursor = state.EventCursor + return pointers, nil + } + if err := json.Unmarshal(data, &pointers.sessions); err != nil { + return nil, err + } + return pointers, nil +} + +func (p *FileSessionPointers) Get(remoteKey string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.sessions[remoteKey] +} + +func (p *FileSessionPointers) Set(remoteKey string, sessionID string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.hasRootConflictLocked(remoteKey, sessionID) { + return fmt.Errorf("session root is already bound to another remote conversation") + } + p.sessions[remoteKey] = sessionID + return p.saveLocked() +} + +func (p *FileSessionPointers) SetParent(sessionID string, parentID string) error { + if sessionID == "" || parentID == "" { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if hasAnyRootConflict(p.sessions, withParent(p.parents, sessionID, parentID)) { + return fmt.Errorf("session root is already bound to another remote conversation") + } + p.parents[sessionID] = parentID + return p.saveLocked() +} + +func (p *FileSessionPointers) RemoteKeyForSession(sessionID string) string { + p.mu.Lock() + defer p.mu.Unlock() + keys := p.remoteKeysForRootLocked(p.rootLocked(sessionID)) + if len(keys) != 1 { + return "" + } + return keys[0] +} + +func (p *FileSessionPointers) RootSession(sessionID string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.rootLocked(sessionID) +} + +func (p *FileSessionPointers) EventCursor() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.eventCursor +} + +func (p *FileSessionPointers) SetEventCursor(cursor string) error { + if cursor == "" { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.eventCursor == cursor { + return nil + } + p.eventCursor = cursor + return p.saveLocked() +} + +func (p *FileSessionPointers) rootLocked(sessionID string) string { + return rootSession(p.parents, sessionID) +} + +func (p *FileSessionPointers) hasRootConflictLocked(remoteKey string, sessionID string) bool { + return hasRootConflict(p.sessions, p.parents, remoteKey, sessionID) +} + +func (p *FileSessionPointers) remoteKeysForRootLocked(root string) []string { + return remoteKeysForRoot(p.sessions, p.parents, root) +} + +func rootSession(parents map[string]string, sessionID string) string { + if sessionID == "" { + return "" + } + seen := map[string]bool{} + current := sessionID + for current != "" && !seen[current] { + seen[current] = true + parent := parents[current] + if parent == "" { + return current + } + current = parent + } + return sessionID +} + +func hasRootConflict(sessions map[string]string, parents map[string]string, remoteKey string, sessionID string) bool { + root := rootSession(parents, sessionID) + if root == "" { + return false + } + for currentKey, currentSession := range sessions { + if currentKey != remoteKey && rootSession(parents, currentSession) == root { + return true + } + } + return false +} + +func hasAnyRootConflict(sessions map[string]string, parents map[string]string) bool { + seen := map[string]string{} + for remoteKey, sessionID := range sessions { + root := rootSession(parents, sessionID) + if root == "" { + continue + } + if current := seen[root]; current != "" && current != remoteKey { + return true + } + seen[root] = remoteKey + } + return false +} + +func withParent(parents map[string]string, sessionID string, parentID string) map[string]string { + next := make(map[string]string, len(parents)+1) + for key, value := range parents { + next[key] = value + } + next[sessionID] = parentID + return next +} + +func remoteKeysForRoot(sessions map[string]string, parents map[string]string, root string) []string { + if root == "" { + return nil + } + var keys []string + for remoteKey, current := range sessions { + if rootSession(parents, current) == root { + keys = append(keys, remoteKey) + } + } + return keys +} + +func (p *FileSessionPointers) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(p.path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(struct { + Sessions map[string]string `json:"sessions"` + Parents map[string]string `json:"parents"` + EventCursor string `json:"eventCursor,omitempty"` + }{ + Sessions: p.sessions, + Parents: p.parents, + EventCursor: p.eventCursor, + }, "", " ") + if err != nil { + return err + } + tempPath := p.path + ".tmp" + if err := os.WriteFile(tempPath, data, 0o600); err != nil { + return err + } + return os.Rename(tempPath, p.path) +} diff --git a/packages/remote-bridge/internal/bridge/session_pointers_test.go b/packages/remote-bridge/internal/bridge/session_pointers_test.go new file mode 100644 index 000000000..cc3a7491a --- /dev/null +++ b/packages/remote-bridge/internal/bridge/session_pointers_test.go @@ -0,0 +1,80 @@ +package bridge + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMemorySessionPointersRejectDuplicateRootBindings(t *testing.T) { + pointers := NewMemorySessionPointers() + if err := pointers.Set("slack:dm:alice", "ses_root"); err != nil { + t.Fatal(err) + } + + if err := pointers.Set("feishu:chat:ops", "ses_root"); err == nil { + t.Fatal("expected duplicate root binding to fail") + } +} + +func TestMemorySessionPointersRejectParentThatCreatesDuplicateRoot(t *testing.T) { + pointers := NewMemorySessionPointers() + if err := pointers.Set("slack:dm:alice", "ses_root"); err != nil { + t.Fatal(err) + } + if err := pointers.Set("feishu:chat:ops", "ses_child"); err != nil { + t.Fatal(err) + } + + if err := pointers.SetParent("ses_child", "ses_root"); err == nil { + t.Fatal("expected parent binding to reject a duplicate root") + } +} + +func TestFileSessionPointersDoNotRestoreAmbiguousRootBindings(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + if err := os.WriteFile(path, []byte(`{ + "sessions": { + "slack:dm:alice": "ses_root", + "feishu:chat:ops": "ses_child" + }, + "parents": { + "ses_child": "ses_root" + } +}`), 0o600); err != nil { + t.Fatal(err) + } + pointers, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + + if got := pointers.RemoteKeyForSession("ses_child"); got != "" { + t.Fatalf("remote key for ambiguous root = %q", got) + } +} + +func TestFileSessionPointersPersistEventCursorWithSessions(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + pointers, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + if err := pointers.Set("feishu:dm:alice", "ses_1"); err != nil { + t.Fatal(err) + } + if err := pointers.SetEventCursor("cursor-2"); err != nil { + t.Fatal(err) + } + + reloaded, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + if got := reloaded.Get("feishu:dm:alice"); got != "ses_1" { + t.Fatalf("session = %q", got) + } + if got := reloaded.EventCursor(); got != "cursor-2" { + t.Fatalf("event cursor = %q", got) + } +} diff --git a/packages/remote-bridge/internal/gateway/gateway.go b/packages/remote-bridge/internal/gateway/gateway.go new file mode 100644 index 000000000..fddbfd628 --- /dev/null +++ b/packages/remote-bridge/internal/gateway/gateway.go @@ -0,0 +1,258 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "sync" + "time" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" + "github.com/astro-han/pawwork/packages/remote-bridge/internal/pawwork" + _ "github.com/astro-han/pawwork/packages/remote-bridge/internal/platforms" + "github.com/chenhg5/cc-connect/core" +) + +type Config struct { + PawWorkBaseURL string `json:"pawWorkBaseURL"` + PawWorkUsername string `json:"pawWorkUsername,omitempty"` + PawWorkPassword string `json:"pawWorkPassword,omitempty"` + StatePath string `json:"statePath"` + Platforms []PlatformConfig `json:"platforms"` +} + +type PlatformConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Options map[string]any `json:"options"` +} + +type App struct { + client *pawwork.Client + engine *bridge.Engine + platforms []core.Platform + eventRetryDelay time.Duration +} + +func LoadConfig(path string) (Config, error) { + file, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer file.Close() + return DecodeConfig(file) +} + +func DecodeConfig(reader io.Reader) (Config, error) { + var config Config + if err := json.NewDecoder(reader).Decode(&config); err != nil { + return Config{}, err + } + return config, nil +} + +func New(config Config) (*App, error) { + if config.PawWorkBaseURL == "" { + return nil, errors.New("pawWorkBaseURL is required") + } + if config.StatePath == "" { + return nil, errors.New("statePath is required") + } + pointers, err := bridge.NewFileSessionPointers(config.StatePath) + if err != nil { + return nil, err + } + client := pawwork.NewWithAuth(config.PawWorkBaseURL, config.PawWorkUsername, config.PawWorkPassword) + client.SetEventCursorStore(pointers) + app := &App{ + client: client, + engine: bridge.NewWithSessionPointers(client, pointers), + eventRetryDelay: time.Second, + } + for _, item := range config.Platforms { + if !item.Enabled { + continue + } + if item.Name == "" { + return nil, errors.New("enabled platform name is required") + } + options := item.Options + if options == nil { + options = map[string]any{} + } + if !hasRemoteAudience(item.Name, options) { + return nil, fmt.Errorf("%s platform requires a specific allow_from or Feishu/Lark allow_chat with group_only", item.Name) + } + platform, err := core.CreatePlatform(item.Name, options) + if err != nil { + return nil, err + } + app.engine.RegisterPlatform(platform) + app.platforms = append(app.platforms, platform) + } + if len(app.platforms) == 0 { + return nil, errors.New("at least one platform must be enabled") + } + return app, nil +} + +func hasRemoteAudience(platform string, options map[string]any) bool { + if allowFrom, ok := options["allow_from"].(string); ok && isSpecificAudience(allowFrom) { + return true + } + if platform != "feishu" && platform != "lark" { + return false + } + allowChat, ok := options["allow_chat"].(string) + return ok && isSpecificAudience(allowChat) && options["group_only"] == true +} + +func isSpecificAudience(value string) bool { + value = strings.TrimSpace(value) + return value != "" && value != "*" +} + +func Run(ctx context.Context, config Config) error { + app, err := New(config) + if err != nil { + return err + } + return app.Run(ctx) +} + +func (a *App) PlatformNames() []string { + names := make([]string, 0, len(a.platforms)) + for _, platform := range a.platforms { + names = append(names, platform.Name()) + } + return names +} + +func (a *App) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + errCh := make(chan error, len(a.platforms)+1) + streamReady := make(chan struct{}) + var streamReadyOnce sync.Once + go func() { + handler := replayRefreshHandler{ + EventHandler: a.engine, + hydrate: a.hydrate, + streamReady: func() { streamReadyOnce.Do(func() { close(streamReady) }) }, + } + for ctx.Err() == nil { + if err := a.client.StreamEvents(ctx, handler); err != nil && ctx.Err() == nil { + if pawwork.IsFatalStreamError(err) { + errCh <- err + return + } + slog.Warn("remote bridge event stream disconnected", "error", err) + } + select { + case <-ctx.Done(): + return + case <-time.After(a.eventRetryDelay): + } + } + }() + select { + case <-streamReady: + case <-ctx.Done(): + a.stopPlatforms() + return nil + case err := <-errCh: + cancel() + a.stopPlatforms() + return err + } + if err := a.hydrate(ctx); err != nil { + cancel() + a.stopPlatforms() + return err + } + for _, platform := range a.platforms { + platform := platform + go func() { + if err := platform.Start(a.messageHandler(ctx)); err != nil && ctx.Err() == nil { + errCh <- fmt.Errorf("%s platform failed: %w", platform.Name(), err) + } + }() + } + + select { + case <-ctx.Done(): + a.stopPlatforms() + return nil + case err := <-errCh: + cancel() + a.stopPlatforms() + return err + } +} + +func (a *App) messageHandler(ctx context.Context) core.MessageHandler { + return func(platform core.Platform, msg *core.Message) { + if err := a.engine.HandleMessage(ctx, platform, msg); err != nil { + slog.Warn("remote bridge failed to handle inbound message", "platform", platform.Name(), "sessionKey", msg.SessionKey, "error", err) + } + } +} + +type replayRefreshHandler struct { + pawwork.EventHandler + hydrate func(context.Context) error + streamReady func() +} + +func (h replayRefreshHandler) HandleReplayRefresh(ctx context.Context) error { + return h.hydrate(ctx) +} + +func (h replayRefreshHandler) HandleStreamReady(context.Context) error { + if h.streamReady != nil { + h.streamReady() + } + return nil +} + +func (a *App) hydrate(ctx context.Context) error { + sessions, err := a.client.ListSessions(ctx, 0) + if err != nil { + return err + } + for _, session := range sessions { + if err := a.engine.HandleSession(ctx, session); err != nil { + return err + } + } + permissions, err := a.client.ListPermissions(ctx) + if err != nil { + return err + } + for _, permission := range permissions { + if err := a.engine.HandlePermission(ctx, permission); err != nil { + slog.Warn("remote bridge could not resurface pending permission", "session", permission.SessionID, "permission", permission.ID, "error", err) + } + } + questions, err := a.client.ListQuestions(ctx) + if err != nil { + return err + } + for _, question := range questions { + if err := a.engine.HandleQuestion(ctx, question); err != nil { + slog.Warn("remote bridge could not resurface pending question", "session", question.SessionID, "message", question.MessageID, "error", err) + } + } + return nil +} + +func (a *App) stopPlatforms() { + for _, platform := range a.platforms { + _ = platform.Stop() + } +} diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go new file mode 100644 index 000000000..cf6bf3215 --- /dev/null +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -0,0 +1,558 @@ +package gateway + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" + "github.com/chenhg5/cc-connect/core" +) + +func TestLoadConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{ + "pawWorkBaseURL": "http://127.0.0.1:4090", + "statePath": "/tmp/pawwork-remote-sessions.json", + "platforms": [{ + "name": "runtime-test", + "enabled": true, + "options": {"token": "secret"} + }] + }`), 0o600); err != nil { + t.Fatal(err) + } + + config, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if config.PawWorkBaseURL != "http://127.0.0.1:4090" || len(config.Platforms) != 1 { + t.Fatalf("config = %#v", config) + } + if config.Platforms[0].Options["token"] != "secret" { + t.Fatalf("platform options = %#v", config.Platforms[0].Options) + } +} + +func TestNewCreatesOnlyEnabledPlatforms(t *testing.T) { + fakePlatforms := 0 + core.RegisterPlatform("runtime-test-enabled", func(opts map[string]any) (core.Platform, error) { + fakePlatforms++ + if opts["token"] != "enabled" { + t.Fatalf("opts = %#v", opts) + } + return &fakePlatform{name: "runtime-test-enabled"}, nil + }) + core.RegisterPlatform("runtime-test-disabled", func(opts map[string]any) (core.Platform, error) { + fakePlatforms++ + return &fakePlatform{name: "runtime-test-disabled"}, nil + }) + + app, err := New(Config{ + PawWorkBaseURL: "http://127.0.0.1:4090", + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{ + {Name: "runtime-test-enabled", Enabled: true, Options: map[string]any{"token": "enabled", "allow_from": "U123"}}, + {Name: "runtime-test-disabled", Enabled: false}, + }, + }) + if err != nil { + t.Fatal(err) + } + if fakePlatforms != 1 { + t.Fatalf("created platforms = %d", fakePlatforms) + } + if got := app.PlatformNames(); len(got) != 1 || got[0] != "runtime-test-enabled" { + t.Fatalf("platforms = %#v", got) + } +} + +func TestNewRejectsWildcardRemoteAudience(t *testing.T) { + fakePlatforms := 0 + core.RegisterPlatform("runtime-test-wildcard", func(map[string]any) (core.Platform, error) { + fakePlatforms++ + return &fakePlatform{name: "runtime-test-wildcard"}, nil + }) + + _, err := New(Config{ + PawWorkBaseURL: "http://127.0.0.1:4090", + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: "runtime-test-wildcard", + Enabled: true, + Options: map[string]any{"allow_from": "*"}, + }}, + }) + + if err == nil || !strings.Contains(err.Error(), "specific allow_from") { + t.Fatalf("err = %v", err) + } + if fakePlatforms != 0 { + t.Fatalf("created platforms = %d", fakePlatforms) + } +} + +func TestRemoteAudienceRejectsWildcardFeishuChat(t *testing.T) { + if hasRemoteAudience("feishu", map[string]any{"allow_chat": "*", "group_only": true}) { + t.Fatal("wildcard Feishu chat audience should not be accepted") + } +} + +func TestHydrateResurfacesPendingInteractions(t *testing.T) { + platform := &fakePlatform{name: "runtime-test-hydrate"} + core.RegisterPlatform("runtime-test-hydrate", func(map[string]any) (core.Platform, error) { + return platform, nil + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/experimental/session": + _, _ = w.Write([]byte(`[ + {"id": "ses_root", "title": "Root"}, + {"id": "ses_child", "title": "Child", "parentID": "ses_root"} + ]`)) + case "/permission": + _, _ = w.Write([]byte(`[{ + "id": "perm_1", + "sessionID": "ses_child", + "permission": "edit", + "patterns": ["/repo/app.ts"] + }]`)) + case "/external-result": + _, _ = w.Write([]byte(`[{ + "part": { + "type": "tool", + "sessionID": "ses_child", + "messageID": "msg_1", + "callID": "call_1", + "tool": "question", + "state": { + "status": "running", + "metadata": {"externalResultReady": true}, + "input": { + "questions": [{"question": "Pick one"}] + } + } + } + }]`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + statePath := filepath.Join(t.TempDir(), "sessions.json") + if err := os.WriteFile(statePath, []byte(`{"runtime-test-hydrate:room:alice":"ses_root"}`), 0o600); err != nil { + t.Fatal(err) + } + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: statePath, + Platforms: []PlatformConfig{{ + Name: "runtime-test-hydrate", + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if err := app.hydrate(t.Context()); err != nil { + t.Fatal(err) + } + + if platform.reconstructKey != "runtime-test-hydrate:room:alice" { + t.Fatalf("reconstruct key = %q", platform.reconstructKey) + } + if len(platform.sends) != 2 { + t.Fatalf("sends = %#v", platform.sends) + } + if !strings.Contains(platform.sends[0], "PawWork asks permission: edit") { + t.Fatalf("permission send = %q", platform.sends[0]) + } + if !strings.Contains(platform.sends[1], "Pick one") { + t.Fatalf("question send = %q", platform.sends[1]) + } +} + +func TestRunStartsEventStreamBeforePlatforms(t *testing.T) { + platform := &fakePlatform{name: "runtime-test-stream-before-platform"} + core.RegisterPlatform("runtime-test-stream-before-platform", func(map[string]any) (core.Platform, error) { + return platform, nil + }) + var streamReady atomic.Bool + streamStarted := make(chan struct{}) + platform.started = make(chan struct{}) + platform.streamReady = &streamReady + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/experimental/session", "/permission", "/external-result": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/global/event": + streamReady.Store(true) + close(streamStarted) + w.Header().Set("content-type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-r.Context().Done() + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: "runtime-test-stream-before-platform", + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- app.Run(ctx) + }() + defer func() { + cancel() + if err := <-errCh; err != nil { + t.Fatal(err) + } + }() + + select { + case <-streamStarted: + case <-time.After(time.Second): + t.Fatal("event stream did not start") + } + select { + case <-platform.started: + case <-time.After(time.Second): + t.Fatal("platform did not start") + } + if !platform.startedAfterStream { + t.Fatal("platform started before the event stream was connected") + } +} + +func TestRunStartsEventStreamBeforeInitialHydrate(t *testing.T) { + platform := &fakePlatform{name: "runtime-test-stream-before-hydrate"} + core.RegisterPlatform("runtime-test-stream-before-hydrate", func(map[string]any) (core.Platform, error) { + return platform, nil + }) + platform.started = make(chan struct{}) + var mu sync.Mutex + order := []string{} + record := func(name string) { + mu.Lock() + defer mu.Unlock() + order = append(order, name) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/experimental/session": + record("hydrate") + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/permission", "/external-result": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/global/event": + record("stream") + w.Header().Set("content-type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-r.Context().Done() + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: "runtime-test-stream-before-hydrate", + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { + errCh <- app.Run(ctx) + }() + defer func() { + cancel() + if err := <-errCh; err != nil { + t.Fatal(err) + } + }() + + select { + case <-platform.started: + case <-time.After(time.Second): + t.Fatal("platform did not start") + } + + mu.Lock() + got := append([]string(nil), order...) + mu.Unlock() + if len(got) < 2 || got[0] != "stream" || got[1] != "hydrate" { + t.Fatalf("request order = %#v", got) + } +} + +func TestHydrateContinuesWhenPendingDeliveryFails(t *testing.T) { + platform := &fakePlatform{name: "runtime-test-hydrate-send-failure", sendErr: errors.New("chat unavailable")} + core.RegisterPlatform("runtime-test-hydrate-send-failure", func(map[string]any) (core.Platform, error) { + return platform, nil + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/experimental/session": + _, _ = w.Write([]byte(`[ + {"id": "ses_root", "title": "Root"}, + {"id": "ses_child", "title": "Child", "parentID": "ses_root"} + ]`)) + case "/permission": + _, _ = w.Write([]byte(`[{ + "id": "perm_1", + "sessionID": "ses_child", + "permission": "edit", + "patterns": ["/repo/app.ts"] + }]`)) + case "/external-result": + _, _ = w.Write([]byte(`[]`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + statePath := filepath.Join(t.TempDir(), "sessions.json") + if err := os.WriteFile(statePath, []byte(`{"runtime-test-hydrate-send-failure:room:alice":"ses_root"}`), 0o600); err != nil { + t.Fatal(err) + } + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: statePath, + Platforms: []PlatformConfig{{ + Name: "runtime-test-hydrate-send-failure", + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if err := app.hydrate(t.Context()); err != nil { + t.Fatalf("hydrate should keep running after a single pending delivery fails: %v", err) + } + if len(platform.sends) != 1 { + t.Fatalf("sends = %#v", platform.sends) + } +} + +func TestRunRetriesTransientEventStreamErrors(t *testing.T) { + platformName := "runtime-test-event-retry" + core.RegisterPlatform(platformName, func(map[string]any) (core.Platform, error) { + return &fakePlatform{name: platformName}, nil + }) + eventRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/experimental/session", "/permission", "/external-result": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/global/event": + eventRequests++ + if eventRequests == 1 { + http.Error(w, "temporary", http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: platformName, + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + app.eventRetryDelay = time.Millisecond + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + + if err := app.Run(ctx); err != nil { + t.Fatal(err) + } + if eventRequests < 2 { + t.Fatalf("event requests = %d", eventRequests) + } +} + +func TestRunHydratesAfterReplayGapSignal(t *testing.T) { + platformName := "runtime-test-event-gap" + core.RegisterPlatform(platformName, func(map[string]any) (core.Platform, error) { + return &fakePlatform{name: platformName}, nil + }) + eventRequests := 0 + permissionRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/experimental/session", "/external-result": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/permission": + permissionRequests++ + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/global/event": + eventRequests++ + w.Header().Set("content-type", "text/event-stream") + if eventRequests == 1 { + _, _ = w.Write([]byte("id: cursor-1\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + return + } + if r.Header.Get("Last-Event-ID") == "" { + t.Fatal("expected reconnect to carry Last-Event-ID") + } + _, _ = w.Write([]byte("id: cursor-2\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: platformName, + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + app.eventRetryDelay = time.Millisecond + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + + if err := app.Run(ctx); err != nil { + t.Fatal(err) + } + if permissionRequests < 2 { + t.Fatalf("permission requests = %d", permissionRequests) + } +} + +func TestMessageHandlerLogsEngineFailures(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + defer slog.SetDefault(previous) + + app := &App{engine: bridge.New(failingSidecar{})} + app.messageHandler(t.Context())(&fakePlatform{name: "runtime-test-message-log"}, &core.Message{ + SessionKey: "runtime-test-message-log:dm:alice", + Content: "start", + }) + + if !strings.Contains(logs.String(), "remote bridge failed to handle inbound message") { + t.Fatalf("logs = %q", logs.String()) + } +} + +type failingSidecar struct{} + +func (failingSidecar) CreateSession(context.Context) (string, error) { + return "", errors.New("sidecar unavailable") +} +func (failingSidecar) SendPrompt(context.Context, string, bridge.Prompt) error { + return errors.New("sidecar unavailable") +} +func (failingSidecar) ListSessions(context.Context, int) ([]bridge.Session, error) { + return nil, errors.New("sidecar unavailable") +} +func (failingSidecar) AbortSession(context.Context, string) (bool, error) { + return false, errors.New("sidecar unavailable") +} +func (failingSidecar) ReplyPermission(context.Context, bridge.PendingPermission, bridge.PermissionReply) error { + return errors.New("sidecar unavailable") +} +func (failingSidecar) SubmitQuestion(context.Context, bridge.PendingQuestion, [][]string) error { + return errors.New("sidecar unavailable") +} + +type fakePlatform struct { + name string + reconstructKey string + sends []string + sendErr error + started chan struct{} + streamReady *atomic.Bool + startedAfterStream bool +} + +func (f *fakePlatform) Name() string { return f.name } +func (f *fakePlatform) Start(core.MessageHandler) error { + if f.started != nil { + f.startedAfterStream = f.streamReady != nil && f.streamReady.Load() + close(f.started) + } + <-context.Background().Done() + return nil +} +func (f *fakePlatform) Reply(context.Context, any, string) error { return nil } +func (f *fakePlatform) Send(_ context.Context, _ any, content string) error { + f.sends = append(f.sends, content) + return f.sendErr +} +func (f *fakePlatform) ReconstructReplyCtx(sessionKey string) (any, error) { + f.reconstructKey = sessionKey + return "restored-reply-context", nil +} +func (f *fakePlatform) Stop() error { return nil } diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go new file mode 100644 index 000000000..9160340fd --- /dev/null +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -0,0 +1,378 @@ +package pawwork + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" +) + +type HTTPStatusError struct { + Method string + Path string + Status string + StatusCode int + Body string +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("%s %s failed: %s %s", e.Method, e.Path, e.Status, e.Body) +} + +func IsFatalStreamError(err error) bool { + var status *HTTPStatusError + if !errors.As(err, &status) { + return false + } + return status.StatusCode == http.StatusUnauthorized || + status.StatusCode == http.StatusForbidden || + status.StatusCode == http.StatusNotFound +} + +type Client struct { + baseURL string + http *http.Client + username string + password string + defaultDirectory string + lastEventID string + eventCursorStore bridge.EventCursorStore + mu sync.Mutex + sessionDirectories map[string]string +} + +func New(baseURL string) *Client { + return NewWithAuth(baseURL, "", "") +} + +func NewWithAuth(baseURL string, username string, password string) *Client { + return NewWithDirectoryAndAuth(baseURL, "", username, password) +} + +func NewWithDirectory(baseURL string, directory string) *Client { + return NewWithDirectoryAndAuth(baseURL, directory, "", "") +} + +func NewWithDirectoryAndAuth(baseURL string, directory string, username string, password string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: http.DefaultClient, + username: username, + password: password, + defaultDirectory: directory, + sessionDirectories: make(map[string]string), + } +} + +func (c *Client) SetEventCursorStore(store bridge.EventCursorStore) { + cursor := "" + if store != nil { + cursor = store.EventCursor() + } + c.mu.Lock() + defer c.mu.Unlock() + c.eventCursorStore = store + if c.lastEventID == "" { + c.lastEventID = cursor + } +} + +func (c *Client) CreateSession(ctx context.Context) (string, error) { + var session struct { + ID string `json:"id"` + Directory string `json:"directory"` + } + if err := c.doJSONWithDirectory(ctx, c.defaultDirectory, http.MethodPost, "/session", map[string]any{}, &session); err != nil { + return "", err + } + directory := session.Directory + if directory == "" { + directory = c.defaultDirectory + } + c.rememberSession(bridge.Session{ID: session.ID, Directory: directory}) + return session.ID, nil +} + +func (c *Client) SendPrompt(ctx context.Context, sessionID string, prompt bridge.Prompt) error { + body := map[string]any{ + "parts": []map[string]string{{ + "type": "text", + "text": prompt.Text, + }}, + } + return c.doSessionJSON(ctx, sessionID, http.MethodPost, "/session/"+url.PathEscape(sessionID)+"/prompt_async", body, nil) +} + +func (c *Client) ListSessions(ctx context.Context, limit int) ([]bridge.Session, error) { + if limit < 0 { + limit = 5 + } + var raw []struct { + ID string `json:"id"` + Title string `json:"title"` + ParentID string `json:"parentID"` + Directory string `json:"directory"` + } + path := "/experimental/session?sort=updated" + if c.defaultDirectory != "" { + path = "/experimental/session?directory=" + url.QueryEscape(c.defaultDirectory) + "&sort=updated" + } + if limit > 0 { + path = fmt.Sprintf("%s&limit=%d", path, limit) + } + if err := c.doJSON(ctx, http.MethodGet, path, nil, &raw); err != nil { + return nil, err + } + sessions := make([]bridge.Session, 0, len(raw)) + for _, item := range raw { + if item.Directory == "" { + item.Directory = c.defaultDirectory + } + session := bridge.Session{ID: item.ID, Title: item.Title, ParentID: item.ParentID, Directory: item.Directory} + c.rememberSession(session) + sessions = append(sessions, session) + } + return sessions, nil +} + +func (c *Client) AbortSession(ctx context.Context, sessionID string) (bool, error) { + var aborted bool + err := c.doSessionJSON(ctx, sessionID, http.MethodPost, "/session/"+url.PathEscape(sessionID)+"/abort", nil, &aborted) + return aborted, err +} + +func (c *Client) ReplyPermission(ctx context.Context, permission bridge.PendingPermission, reply bridge.PermissionReply) error { + body := map[string]any{"reply": reply.Reply} + if reply.Message != "" { + body["message"] = reply.Message + } + directory := permission.Directory + if directory == "" { + var err error + directory, err = c.directoryForSession(ctx, permission.SessionID) + if err != nil { + return err + } + } + return c.doJSONWithDirectory(ctx, directory, http.MethodPost, "/permission/"+url.PathEscape(permission.ID)+"/reply", body, nil) +} + +func (c *Client) SubmitQuestion(ctx context.Context, pending bridge.PendingQuestion, answers [][]string) error { + body := map[string]any{ + "kind": "submit", + "messageID": pending.MessageID, + "callID": pending.CallID, + "payload": map[string]any{ + "answers": answers, + }, + } + directory := pending.Directory + if directory == "" { + var err error + directory, err = c.directoryForSession(ctx, pending.SessionID) + if err != nil { + return err + } + } + return c.doJSONWithDirectory(ctx, directory, http.MethodPost, "/session/"+url.PathEscape(pending.SessionID)+"/tool/respond", body, nil) +} + +func (c *Client) ListPermissions(ctx context.Context) ([]bridge.PendingPermission, error) { + permissions := []bridge.PendingPermission{} + for _, directory := range c.knownDirectories() { + var raw []struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns"` + } + if err := c.doJSONWithDirectory(ctx, directory, http.MethodGet, "/permission", nil, &raw); err != nil { + if !canSkipHydrationDirectoryError(err) { + return nil, err + } + slog.Warn("remote bridge could not list permissions", "directory", directory, "error", err) + continue + } + for _, item := range raw { + permissions = append(permissions, bridge.PendingPermission{ + ID: item.ID, + SessionID: item.SessionID, + Permission: item.Permission, + Patterns: item.Patterns, + Directory: directory, + }) + } + } + return permissions, nil +} + +func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, error) { + questions := []bridge.PendingQuestion{} + for _, directory := range c.knownDirectories() { + var raw []struct { + Part json.RawMessage `json:"part"` + } + if err := c.doJSONWithDirectory(ctx, directory, http.MethodGet, "/external-result", nil, &raw); err != nil { + if !canSkipHydrationDirectoryError(err) { + return nil, err + } + slog.Warn("remote bridge could not list questions", "directory", directory, "error", err) + continue + } + for _, item := range raw { + data, err := json.Marshal(struct { + Part json.RawMessage `json:"part"` + }{Part: item.Part}) + if err != nil { + return nil, err + } + question, ok, _, _, err := questionUpdateFromEvent(data, directory) + if err != nil { + return nil, err + } + if ok { + questions = append(questions, question) + } + } + } + return questions, nil +} + +func canSkipHydrationDirectoryError(err error) bool { + var status *HTTPStatusError + if !errors.As(err, &status) { + return true + } + return status.StatusCode == http.StatusRequestTimeout || + status.StatusCode == http.StatusTooManyRequests || + status.StatusCode >= 500 +} + +func (c *Client) doJSON(ctx context.Context, method string, path string, input any, output any) error { + return c.doJSONWithDirectory(ctx, "", method, path, input, output) +} + +func (c *Client) doSessionJSON(ctx context.Context, sessionID string, method string, path string, input any, output any) error { + directory, err := c.directoryForSession(ctx, sessionID) + if err != nil { + return err + } + return c.doJSONWithDirectory(ctx, directory, method, path, input, output) +} + +func (c *Client) doJSONWithDirectory(ctx context.Context, directory string, method string, path string, input any, output any) error { + var body io.Reader + if input != nil { + data, err := json.Marshal(input) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + if input != nil { + req.Header.Set("content-type", "application/json") + } + if directory != "" { + req.Header.Set("x-opencode-directory", directory) + } + c.authorize(req) + res, err := c.http.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return &HTTPStatusError{ + Method: method, + Path: path, + Status: res.Status, + StatusCode: res.StatusCode, + Body: strings.TrimSpace(string(data)), + } + } + if output == nil { + io.Copy(io.Discard, res.Body) + return nil + } + return json.NewDecoder(res.Body).Decode(output) +} + +func (c *Client) directoryForSession(ctx context.Context, sessionID string) (string, error) { + if sessionID == "" { + return c.defaultDirectory, nil + } + c.mu.Lock() + directory := c.sessionDirectories[sessionID] + c.mu.Unlock() + if directory != "" { + return directory, nil + } + var session struct { + ID string `json:"id"` + Directory string `json:"directory"` + } + if err := c.doJSON(ctx, http.MethodGet, "/session/"+url.PathEscape(sessionID), nil, &session); err != nil { + return "", err + } + c.rememberSession(bridge.Session{ID: session.ID, Directory: session.Directory}) + if session.Directory != "" { + return session.Directory, nil + } + return c.defaultDirectory, nil +} + +func (c *Client) rememberSession(session bridge.Session) { + if session.ID == "" || session.Directory == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.sessionDirectories[session.ID] = session.Directory +} + +func (c *Client) knownDirectories() []string { + c.mu.Lock() + defer c.mu.Unlock() + seen := make(map[string]bool, len(c.sessionDirectories)+1) + directories := []string{} + if c.defaultDirectory != "" { + seen[c.defaultDirectory] = true + directories = append(directories, c.defaultDirectory) + } + for _, directory := range c.sessionDirectories { + if directory == "" || seen[directory] { + continue + } + seen[directory] = true + directories = append(directories, directory) + } + if len(directories) == 0 { + return []string{""} + } + return directories +} + +func (c *Client) authorize(req *http.Request) { + if c.username == "" && c.password == "" { + return + } + username := c.username + if username == "" { + username = "opencode" + } + req.SetBasicAuth(username, c.password) +} diff --git a/packages/remote-bridge/internal/pawwork/client_test.go b/packages/remote-bridge/internal/pawwork/client_test.go new file mode 100644 index 000000000..12a8929ee --- /dev/null +++ b/packages/remote-bridge/internal/pawwork/client_test.go @@ -0,0 +1,551 @@ +package pawwork + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" +) + +func TestClientUsesPawWorkSessionEndpoints(t *testing.T) { + var promptBody map[string]any + var promptDirectory string + var abortDirectory string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/session": + writeJSON(t, w, map[string]any{"id": "ses_new", "title": "Remote", "directory": "/default"}) + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + if r.URL.Query().Get("limit") != "5" || r.URL.Query().Get("sort") != "updated" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + writeJSON(t, w, []map[string]any{ + {"id": "ses_a", "title": "Plan", "directory": "/repo/a"}, + {"id": "ses_b", "parentID": "ses_a", "directory": "/repo/a"}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_a/prompt_async": + promptDirectory = r.Header.Get("x-opencode-directory") + if err := json.NewDecoder(r.Body).Decode(&promptBody); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_a/abort": + abortDirectory = r.Header.Get("x-opencode-directory") + writeJSON(t, w, true) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + sessionID, err := client.CreateSession(t.Context()) + if err != nil { + t.Fatal(err) + } + if sessionID != "ses_new" { + t.Fatalf("sessionID = %q", sessionID) + } + + sessions, err := client.ListSessions(t.Context(), 5) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 2 || sessions[0] != (bridge.Session{ID: "ses_a", Title: "Plan", Directory: "/repo/a"}) || sessions[1] != (bridge.Session{ID: "ses_b", ParentID: "ses_a", Directory: "/repo/a"}) { + t.Fatalf("sessions = %#v", sessions) + } + + if err := client.SendPrompt(t.Context(), "ses_a", bridge.Prompt{Text: "hello"}); err != nil { + t.Fatal(err) + } + if got := promptBody["parts"]; got == nil { + t.Fatalf("prompt body = %#v", promptBody) + } + if promptDirectory != "/repo/a" { + t.Fatalf("prompt directory = %q", promptDirectory) + } + + aborted, err := client.AbortSession(t.Context(), "ses_a") + if err != nil { + t.Fatal(err) + } + if !aborted { + t.Fatal("expected aborted run") + } + if abortDirectory != "/repo/a" { + t.Fatalf("abort directory = %q", abortDirectory) + } +} + +func TestClientFetchesSessionDirectoryWhenMissing(t *testing.T) { + var promptDirectory string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/session/ses_unknown": + writeJSON(t, w, map[string]any{"id": "ses_unknown", "directory": "/repo/unknown"}) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_unknown/prompt_async": + promptDirectory = r.Header.Get("x-opencode-directory") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if err := client.SendPrompt(t.Context(), "ses_unknown", bridge.Prompt{Text: "hello"}); err != nil { + t.Fatal(err) + } + if promptDirectory != "/repo/unknown" { + t.Fatalf("prompt directory = %q", promptDirectory) + } +} + +func TestClientSendsBasicAuthWhenConfigured(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("authorization") != "Basic UGF3V29yazpwYXNz" { + t.Fatalf("authorization = %q", r.Header.Get("authorization")) + } + writeJSON(t, w, map[string]any{"id": "ses_new"}) + })) + defer server.Close() + + client := NewWithAuth(server.URL, "PawWork", "pass") + if _, err := client.CreateSession(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestClientRepliesToPermissionAndQuestion(t *testing.T) { + var permissionBody map[string]any + var questionBody map[string]any + var permissionDirectory string + var questionDirectory string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/permission/perm_1/reply": + permissionDirectory = r.Header.Get("x-opencode-directory") + if err := json.NewDecoder(r.Body).Decode(&permissionBody); err != nil { + t.Fatal(err) + } + writeJSON(t, w, true) + case r.Method == http.MethodPost && r.URL.Path == "/session/ses_1/tool/respond": + questionDirectory = r.Header.Get("x-opencode-directory") + if err := json.NewDecoder(r.Body).Decode(&questionBody); err != nil { + t.Fatal(err) + } + writeJSON(t, w, map[string]any{"status": "ok"}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + err := client.ReplyPermission(t.Context(), bridge.PendingPermission{ + ID: "perm_1", + SessionID: "ses_1", + Directory: "/repo/interactions", + }, bridge.PermissionReply{Reply: "once", Message: "go"}) + if err != nil { + t.Fatal(err) + } + if permissionBody["reply"] != "once" || permissionBody["message"] != "go" { + t.Fatalf("permission body = %#v", permissionBody) + } + if permissionDirectory != "/repo/interactions" { + t.Fatalf("permission directory = %q", permissionDirectory) + } + + pending := bridge.PendingQuestion{SessionID: "ses_1", MessageID: "msg_1", CallID: "call_1", Directory: "/repo/interactions"} + if err := client.SubmitQuestion(t.Context(), pending, [][]string{{"A"}}); err != nil { + t.Fatal(err) + } + if questionBody["kind"] != "submit" || questionBody["messageID"] != "msg_1" || questionBody["callID"] != "call_1" { + t.Fatalf("question body = %#v", questionBody) + } + payload := questionBody["payload"].(map[string]any) + if payload["answers"] == nil { + t.Fatalf("question payload = %#v", payload) + } + if questionDirectory != "/repo/interactions" { + t.Fatalf("question directory = %q", questionDirectory) + } +} + +func TestClientListsPendingInteractions(t *testing.T) { + permissionDirectories := []string{} + questionDirectories := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{{"id": "ses_1", "directory": "/repo/a"}}) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + permissionDirectories = append(permissionDirectories, r.Header.Get("x-opencode-directory")) + writeJSON(t, w, []map[string]any{{ + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": []string{"/repo/app.ts"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/external-result": + questionDirectories = append(questionDirectories, r.Header.Get("x-opencode-directory")) + writeJSON(t, w, []map[string]any{{ + "part": map[string]any{ + "type": "tool", + "sessionID": "ses_1", + "messageID": "msg_1", + "callID": "call_1", + "tool": "question", + "state": map[string]any{ + "status": "running", + "metadata": map[string]any{"externalResultReady": true}, + "input": map[string]any{ + "questions": []map[string]any{{ + "header": "Approach", + "question": "Pick one", + "options": []map[string]any{{"label": "A", "description": "Small"}, {"label": "B", "description": "Large"}}, + }}, + }, + }, + }, + }}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + permissions, err := client.ListPermissions(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(permissions) != 1 || permissions[0].ID != "perm_1" || permissions[0].Patterns[0] != "/repo/app.ts" || permissions[0].Directory != "/repo/a" { + t.Fatalf("permissions = %#v", permissions) + } + if len(permissionDirectories) != 1 || permissionDirectories[0] != "/repo/a" { + t.Fatalf("permission directories = %#v", permissionDirectories) + } + + questions, err := client.ListQuestions(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(questions) != 1 || questions[0].CallID != "call_1" || questions[0].Questions[0].Options[1].Label != "B" || questions[0].Directory != "/repo/a" { + t.Fatalf("questions = %#v", questions) + } + if len(questionDirectories) != 1 || questionDirectories[0] != "/repo/a" { + t.Fatalf("question directories = %#v", questionDirectories) + } +} + +func TestClientListPermissionsSkipsFailingDirectory(t *testing.T) { + permissionDirectories := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{ + {"id": "ses_a", "directory": "/repo/a"}, + {"id": "ses_b", "directory": "/repo/b"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + directory := r.Header.Get("x-opencode-directory") + permissionDirectories = append(permissionDirectories, directory) + if directory == "/repo/a" { + http.Error(w, "temporary failure", http.StatusBadGateway) + return + } + writeJSON(t, w, []map[string]any{{ + "id": "perm_b", + "sessionID": "ses_b", + "permission": "edit", + }}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + permissions, err := client.ListPermissions(t.Context()) + if err != nil { + t.Fatal(err) + } + + if len(permissions) != 1 || permissions[0].ID != "perm_b" || permissions[0].Directory != "/repo/b" { + t.Fatalf("permissions = %#v", permissions) + } + if len(permissionDirectories) != 2 || permissionDirectories[0] != "/repo/a" || permissionDirectories[1] != "/repo/b" { + t.Fatalf("permission directories = %#v", permissionDirectories) + } +} + +func TestClientListPermissionsReturnsFatalDirectoryError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{{"id": "ses_a", "directory": "/repo/a"}}) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + http.Error(w, "forbidden", http.StatusForbidden) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + _, err := client.ListPermissions(t.Context()) + + var status *HTTPStatusError + if !errors.As(err, &status) || status.StatusCode != http.StatusForbidden { + t.Fatalf("err = %#v", err) + } +} + +func TestClientListQuestionsSkipsFailingDirectory(t *testing.T) { + questionDirectories := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{ + {"id": "ses_a", "directory": "/repo/a"}, + {"id": "ses_b", "directory": "/repo/b"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/external-result": + directory := r.Header.Get("x-opencode-directory") + questionDirectories = append(questionDirectories, directory) + if directory == "/repo/a" { + http.Error(w, "temporary failure", http.StatusBadGateway) + return + } + writeJSON(t, w, []map[string]any{{ + "part": map[string]any{ + "type": "tool", + "sessionID": "ses_b", + "messageID": "msg_b", + "callID": "call_b", + "tool": "question", + "state": map[string]any{ + "status": "running", + "metadata": map[string]any{"externalResultReady": true}, + "input": map[string]any{ + "questions": []map[string]any{{"question": "Pick one"}}, + }, + }, + }, + }}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + questions, err := client.ListQuestions(t.Context()) + if err != nil { + t.Fatal(err) + } + + if len(questions) != 1 || questions[0].CallID != "call_b" || questions[0].Directory != "/repo/b" { + t.Fatalf("questions = %#v", questions) + } + if len(questionDirectories) != 2 || questionDirectories[0] != "/repo/a" || questionDirectories[1] != "/repo/b" { + t.Fatalf("question directories = %#v", questionDirectories) + } +} + +func TestClientListQuestionsReturnsFatalDirectoryError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{{"id": "ses_a", "directory": "/repo/a"}}) + case r.Method == http.MethodGet && r.URL.Path == "/external-result": + http.Error(w, "missing", http.StatusNotFound) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + _, err := client.ListQuestions(t.Context()) + + var status *HTTPStatusError + if !errors.As(err, &status) || status.StatusCode != http.StatusNotFound { + t.Fatalf("err = %#v", err) + } +} + +func TestClientUsesLastEventIDOnReconnect(t *testing.T) { + lastEventID := "" + seen := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Get("Last-Event-ID")) + w.Header().Set("content-type", "text/event-stream") + if len(seen) == 1 { + _, _ = w.Write([]byte("id: cursor-1\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + return + } + lastEventID = r.Header.Get("Last-Event-ID") + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + })) + defer server.Close() + + client := New(server.URL) + if err := client.StreamEvents(t.Context(), &fakeEventHandler{}); err != nil { + t.Fatal(err) + } + if err := client.StreamEvents(t.Context(), &fakeEventHandler{}); err != nil { + t.Fatal(err) + } + if lastEventID != "cursor-1" { + t.Fatalf("last event id = %q; seen = %#v", lastEventID, seen) + } +} + +func TestClientPersistsLastEventIDAcrossRestarts(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "sessions.json") + store, err := bridge.NewFileSessionPointers(statePath) + if err != nil { + t.Fatal(err) + } + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("content-type", "text/event-stream") + switch requests { + case 1: + if got := r.Header.Get("Last-Event-ID"); got != "" { + t.Fatalf("first Last-Event-ID = %q", got) + } + _, _ = w.Write([]byte("id: cursor-1\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + case 2: + if got := r.Header.Get("Last-Event-ID"); got != "cursor-1" { + t.Fatalf("second Last-Event-ID = %q", got) + } + _, _ = w.Write([]byte("id: cursor-2\ndata: {\"payload\":{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"sessionID\":\"ses_1\",\"text\":\"done\",\"time\":{\"end\":2}}}}}\n\n")) + case 3: + if got := r.Header.Get("Last-Event-ID"); got != "cursor-2" { + t.Fatalf("third Last-Event-ID = %q", got) + } + _, _ = w.Write([]byte("id: cursor-3\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + default: + t.Fatalf("unexpected stream request %d", requests) + } + })) + defer server.Close() + + first := New(server.URL) + first.SetEventCursorStore(store) + if err := first.StreamEvents(t.Context(), &fakeEventHandler{}); err != nil { + t.Fatal(err) + } + + reloaded, err := bridge.NewFileSessionPointers(statePath) + if err != nil { + t.Fatal(err) + } + second := New(server.URL) + second.SetEventCursorStore(reloaded) + secondHandler := &fakeEventHandler{} + if err := second.StreamEvents(t.Context(), secondHandler); err != nil { + t.Fatal(err) + } + if len(secondHandler.texts) != 1 || secondHandler.texts[0] != (eventText{sessionID: "ses_1", text: "done"}) { + t.Fatalf("second texts = %#v", secondHandler.texts) + } + + reloaded, err = bridge.NewFileSessionPointers(statePath) + if err != nil { + t.Fatal(err) + } + third := New(server.URL) + third.SetEventCursorStore(reloaded) + thirdHandler := &fakeEventHandler{} + if err := third.StreamEvents(t.Context(), thirdHandler); err != nil { + t.Fatal(err) + } + if len(thirdHandler.texts) != 0 { + t.Fatalf("third texts = %#v", thirdHandler.texts) + } +} + +func TestClientKeepsLastEventIDWhenReplayRefreshFails(t *testing.T) { + seen := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Get("Last-Event-ID")) + w.Header().Set("content-type", "text/event-stream") + switch len(seen) { + case 1: + _, _ = w.Write([]byte("id: cursor-1\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + case 2: + _, _ = w.Write([]byte("id: cursor-2\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + default: + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) + } + })) + defer server.Close() + + client := New(server.URL) + if err := client.StreamEvents(t.Context(), &fakeEventHandler{}); err != nil { + t.Fatal(err) + } + handler := &fakeEventHandler{refreshErr: errors.New("hydrate failed")} + if err := client.StreamEvents(t.Context(), handler); err == nil { + t.Fatal("expected replay refresh failure") + } + if err := client.StreamEvents(t.Context(), &fakeEventHandler{}); err != nil { + t.Fatal(err) + } + + if len(seen) != 3 || seen[1] != "cursor-1" || seen[2] != "cursor-1" { + t.Fatalf("Last-Event-ID headers = %#v", seen) + } + if handler.refreshes != 1 { + t.Fatalf("refreshes = %d", handler.refreshes) + } +} + +func TestClientEscapesDirectoryQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.RawQuery; got != "directory="+url.QueryEscape("/repo/space here")+"&sort=updated&limit=5" { + t.Fatalf("query = %q", got) + } + writeJSON(t, w, []map[string]any{}) + })) + defer server.Close() + + client := NewWithDirectory(server.URL, "/repo/space here") + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("content-type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} diff --git a/packages/remote-bridge/internal/pawwork/events.go b/packages/remote-bridge/internal/pawwork/events.go new file mode 100644 index 000000000..5d72fdaa9 --- /dev/null +++ b/packages/remote-bridge/internal/pawwork/events.go @@ -0,0 +1,406 @@ +package pawwork + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "strings" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" +) + +type EventHandler interface { + HandleAssistantText(context.Context, string, string) error + HandlePermission(context.Context, bridge.PendingPermission) error + HandlePermissionResolved(context.Context, bridge.PermissionResolution) error + HandleQuestion(context.Context, bridge.PendingQuestion) error + HandleQuestionResolved(context.Context, bridge.QuestionResolution) error + HandleSession(context.Context, bridge.Session) error +} + +type ReplayRefreshHandler interface { + HandleReplayRefresh(context.Context) error +} + +type StreamReadyHandler interface { + HandleStreamReady(context.Context) error +} + +type clientEventHandler struct { + client *Client + next EventHandler + reconnecting bool +} + +func (h clientEventHandler) HandleAssistantText(ctx context.Context, sessionID string, text string) error { + return h.next.HandleAssistantText(ctx, sessionID, text) +} + +func (h clientEventHandler) HandlePermission(ctx context.Context, permission bridge.PendingPermission) error { + h.client.rememberSession(bridge.Session{ID: permission.SessionID, Directory: permission.Directory}) + return h.next.HandlePermission(ctx, permission) +} + +func (h clientEventHandler) HandlePermissionResolved(ctx context.Context, resolution bridge.PermissionResolution) error { + h.client.rememberSession(bridge.Session{ID: resolution.SessionID, Directory: resolution.Directory}) + return h.next.HandlePermissionResolved(ctx, resolution) +} + +func (h clientEventHandler) HandleQuestion(ctx context.Context, question bridge.PendingQuestion) error { + h.client.rememberSession(bridge.Session{ID: question.SessionID, Directory: question.Directory}) + return h.next.HandleQuestion(ctx, question) +} + +func (h clientEventHandler) HandleQuestionResolved(ctx context.Context, resolution bridge.QuestionResolution) error { + h.client.rememberSession(bridge.Session{ID: resolution.SessionID, Directory: resolution.Directory}) + return h.next.HandleQuestionResolved(ctx, resolution) +} + +func (h clientEventHandler) HandleSession(ctx context.Context, session bridge.Session) error { + h.client.rememberSession(session) + return h.next.HandleSession(ctx, session) +} + +func (h clientEventHandler) HandleReplayRefresh(ctx context.Context) error { + if !h.reconnecting { + return nil + } + next, ok := h.next.(ReplayRefreshHandler) + if !ok { + return nil + } + if err := next.HandleReplayRefresh(ctx); err != nil { + return replayRefreshError{err: err} + } + return nil +} + +type replayRefreshError struct { + err error +} + +func (e replayRefreshError) Error() string { return e.err.Error() } +func (e replayRefreshError) Unwrap() error { return e.err } + +func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/global/event", nil) + if err != nil { + return err + } + req.Header.Set("accept", "text/event-stream") + lastEventID := c.lastEventIDValue() + if lastEventID != "" { + req.Header.Set("Last-Event-ID", lastEventID) + } + c.authorize(req) + res, err := c.http.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return &HTTPStatusError{ + Method: http.MethodGet, + Path: "/global/event", + Status: res.Status, + StatusCode: res.StatusCode, + Body: strings.TrimSpace(string(data)), + } + } + mediaType, _, err := mime.ParseMediaType(res.Header.Get("content-type")) + if err != nil || !strings.EqualFold(mediaType, "text/event-stream") { + return fmt.Errorf("GET /global/event failed: expected text/event-stream, got %q", res.Header.Get("content-type")) + } + if ready, ok := handler.(StreamReadyHandler); ok { + if err := ready.HandleStreamReady(ctx); err != nil { + return err + } + } + return parseSSE(ctx, res.Body, clientEventHandler{client: c, next: handler, reconnecting: lastEventID != ""}, c.setLastEventID) +} + +func DispatchEvent(ctx context.Context, data []byte, handler EventHandler) error { + var envelope struct { + Directory string `json:"directory"` + Payload struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` + } `json:"payload"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return err + } + switch envelope.Payload.Type { + case "server.connected": + refresh, ok := handler.(ReplayRefreshHandler) + if !ok { + return nil + } + return refresh.HandleReplayRefresh(ctx) + case "message.part.delta": + return nil + case "permission.asked": + var permission bridge.PendingPermission + if err := json.Unmarshal(envelope.Payload.Properties, &permission); err != nil { + return err + } + if permission.ID == "" || permission.SessionID == "" { + return nil + } + permission.Directory = envelope.Directory + return handler.HandlePermission(ctx, permission) + case "permission.replied": + resolution, ok, err := permissionResolutionFromEvent(envelope.Payload.Properties) + if err != nil || !ok { + return err + } + if resolution.Directory == "" { + resolution.Directory = envelope.Directory + } + return handler.HandlePermissionResolved(ctx, resolution) + case "session.created": + session, ok, err := sessionFromEvent(envelope.Payload.Properties, envelope.Directory) + if err != nil || !ok { + return err + } + return handler.HandleSession(ctx, session) + case "message.part.updated": + question, questionPending, resolution, questionResolved, err := questionUpdateFromEvent(envelope.Payload.Properties, envelope.Directory) + if err != nil { + return err + } + if questionPending { + return handler.HandleQuestion(ctx, question) + } + if questionResolved { + return handler.HandleQuestionResolved(ctx, resolution) + } + text, ok, err := assistantTextFromEvent(envelope.Payload.Properties) + if err != nil || !ok { + return err + } + return handler.HandleAssistantText(ctx, text.sessionID, text.text) + default: + return nil + } +} + +func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLastEventID func(string) error) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + var data strings.Builder + var eventID string + for scanner.Scan() { + if err := ctx.Err(); err != nil { + return err + } + line := scanner.Text() + if line == "" { + if data.Len() > 0 { + if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { + var refresh replayRefreshError + if errors.As(err, &refresh) { + return err + } + slog.Warn("remote bridge ignored event", "error", err) + } + data.Reset() + } + if eventID != "" { + if err := setLastEventID(eventID); err != nil { + return err + } + eventID = "" + } + continue + } + if strings.HasPrefix(line, "id:") { + value := strings.TrimPrefix(line, "id:") + eventID = strings.TrimSpace(value) + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + if data.Len() > 0 { + data.WriteByte('\n') + } + value := strings.TrimPrefix(line, "data:") + data.WriteString(strings.TrimPrefix(value, " ")) + } + if err := scanner.Err(); err != nil { + return err + } + if data.Len() > 0 { + if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { + var refresh replayRefreshError + if errors.As(err, &refresh) { + return err + } + slog.Warn("remote bridge ignored event", "error", err) + } + if eventID != "" { + if err := setLastEventID(eventID); err != nil { + return err + } + } + } + return nil +} + +type assistantText struct { + sessionID string + text string +} + +func assistantTextFromEvent(data json.RawMessage) (assistantText, bool, error) { + var props struct { + Part struct { + Type string `json:"type"` + SessionID string `json:"sessionID"` + Text string `json:"text"` + Ignored bool `json:"ignored"` + Time struct { + End *float64 `json:"end"` + } `json:"time"` + } `json:"part"` + } + if err := json.Unmarshal(data, &props); err != nil { + return assistantText{}, false, err + } + part := props.Part + if part.Type != "text" || part.Ignored || part.Time.End == nil || part.SessionID == "" || part.Text == "" { + return assistantText{}, false, nil + } + return assistantText{sessionID: part.SessionID, text: part.Text}, true, nil +} + +func permissionResolutionFromEvent(data json.RawMessage) (bridge.PermissionResolution, bool, error) { + var resolution bridge.PermissionResolution + if err := json.Unmarshal(data, &resolution); err != nil { + return bridge.PermissionResolution{}, false, err + } + if resolution.SessionID == "" && resolution.RequestID == "" { + return bridge.PermissionResolution{}, false, nil + } + return resolution, true, nil +} + +func sessionFromEvent(data json.RawMessage, directory string) (bridge.Session, bool, error) { + var props struct { + Info struct { + ID string `json:"id"` + Title string `json:"title"` + ParentID string `json:"parentID"` + Directory string `json:"directory"` + } `json:"info"` + } + if err := json.Unmarshal(data, &props); err != nil { + return bridge.Session{}, false, err + } + if props.Info.ID == "" { + return bridge.Session{}, false, nil + } + if props.Info.Directory != "" { + directory = props.Info.Directory + } + return bridge.Session{ID: props.Info.ID, Title: props.Info.Title, ParentID: props.Info.ParentID, Directory: directory}, true, nil +} + +func questionUpdateFromEvent(data json.RawMessage, directory string) ( + bridge.PendingQuestion, + bool, + bridge.QuestionResolution, + bool, + error, +) { + var props struct { + Part struct { + Type string `json:"type"` + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State struct { + Status string `json:"status"` + Metadata struct { + ExternalResultReady bool `json:"externalResultReady"` + } `json:"metadata"` + Input struct { + Questions []bridge.Question `json:"questions"` + } `json:"input"` + } `json:"state"` + } `json:"part"` + } + if err := json.Unmarshal(data, &props); err != nil { + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, err + } + part := props.Part + if part.Type != "tool" || part.Tool != "question" { + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil + } + if part.SessionID == "" || part.MessageID == "" || part.CallID == "" { + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil + } + resolution := bridge.QuestionResolution{ + SessionID: part.SessionID, + MessageID: part.MessageID, + CallID: part.CallID, + Directory: directory, + } + if part.State.Status != "running" { + if part.State.Status == "" || part.State.Status == "pending" { + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil + } + return bridge.PendingQuestion{}, false, resolution, true, nil + } + if !part.State.Metadata.ExternalResultReady { + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil + } + return bridge.PendingQuestion{ + SessionID: part.SessionID, + MessageID: part.MessageID, + CallID: part.CallID, + Questions: part.State.Input.Questions, + Directory: directory, + }, true, bridge.QuestionResolution{}, false, nil +} + +func (c *Client) lastEventIDValue() string { + c.mu.Lock() + lastEventID := c.lastEventID + store := c.eventCursorStore + c.mu.Unlock() + if lastEventID != "" { + return lastEventID + } + if store == nil { + return "" + } + return store.EventCursor() +} + +func (c *Client) setLastEventID(id string) error { + if id == "" { + return nil + } + c.mu.Lock() + store := c.eventCursorStore + c.mu.Unlock() + if store != nil { + if err := store.SetEventCursor(id); err != nil { + return err + } + } + c.mu.Lock() + defer c.mu.Unlock() + c.lastEventID = id + return nil +} diff --git a/packages/remote-bridge/internal/pawwork/events_test.go b/packages/remote-bridge/internal/pawwork/events_test.go new file mode 100644 index 000000000..ba06ef7fb --- /dev/null +++ b/packages/remote-bridge/internal/pawwork/events_test.go @@ -0,0 +1,406 @@ +package pawwork + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" +) + +type fakeEventHandler struct { + texts []eventText + textErr error + refreshes int + refreshErr error + permissions []bridge.PendingPermission + resolvedPermissions []bridge.PermissionResolution + questions []bridge.PendingQuestion + resolvedQuestions []bridge.QuestionResolution + sessions []bridge.Session +} + +type streamReadyEventHandler struct { + fakeEventHandler + ready int +} + +func (f *streamReadyEventHandler) HandleStreamReady(context.Context) error { + f.ready++ + return nil +} + +type eventText struct { + sessionID string + text string +} + +func (f *fakeEventHandler) HandleAssistantText(_ context.Context, sessionID string, text string) error { + f.texts = append(f.texts, eventText{sessionID: sessionID, text: text}) + if f.textErr != nil { + return f.textErr + } + return nil +} + +func (f *fakeEventHandler) HandlePermission(_ context.Context, permission bridge.PendingPermission) error { + f.permissions = append(f.permissions, permission) + return nil +} + +func (f *fakeEventHandler) HandlePermissionResolved(_ context.Context, resolution bridge.PermissionResolution) error { + f.resolvedPermissions = append(f.resolvedPermissions, resolution) + return nil +} + +func (f *fakeEventHandler) HandleQuestion(_ context.Context, question bridge.PendingQuestion) error { + f.questions = append(f.questions, question) + return nil +} + +func (f *fakeEventHandler) HandleQuestionResolved(_ context.Context, resolution bridge.QuestionResolution) error { + f.resolvedQuestions = append(f.resolvedQuestions, resolution) + return nil +} + +func (f *fakeEventHandler) HandleSession(_ context.Context, session bridge.Session) error { + f.sessions = append(f.sessions, session) + return nil +} + +func (f *fakeEventHandler) HandleReplayRefresh(_ context.Context) error { + f.refreshes++ + return f.refreshErr +} + +func TestDispatchEventRoutesAssistantText(t *testing.T) { + handler := &fakeEventHandler{} + err := DispatchEvent(t.Context(), []byte(`{ + "payload": { + "type": "message.part.updated", + "properties": { + "part": { + "type": "text", + "sessionID": "ses_1", + "messageID": "msg_1", + "id": "prt_1", + "text": "hello", + "time": {"start": 1, "end": 2} + } + } + } + }`), handler) + if err != nil { + t.Fatal(err) + } + if len(handler.texts) != 1 || handler.texts[0] != (eventText{sessionID: "ses_1", text: "hello"}) { + t.Fatalf("texts = %#v", handler.texts) + } +} + +func TestDispatchEventIgnoresStreamingDeltaAndReasoning(t *testing.T) { + handler := &fakeEventHandler{} + delta := []byte(`{ + "payload": { + "type": "message.part.delta", + "properties": { + "sessionID": "ses_1", + "messageID": "msg_1", + "partID": "prt_1", + "field": "text", + "delta": " hello" + } + } + }`) + if err := DispatchEvent(t.Context(), delta, handler); err != nil { + t.Fatal(err) + } + reasoning := []byte(`{ + "payload": { + "type": "message.part.updated", + "properties": { + "part": { + "type": "reasoning", + "sessionID": "ses_1", + "messageID": "msg_1", + "id": "prt_reasoning", + "text": "private reasoning", + "time": {"start": 1, "end": 2} + } + } + } + }`) + if err := DispatchEvent(t.Context(), reasoning, handler); err != nil { + t.Fatal(err) + } + if len(handler.texts) != 0 { + t.Fatalf("texts = %#v", handler.texts) + } +} + +func TestDispatchEventRoutesPermissionAndQuestion(t *testing.T) { + handler := &fakeEventHandler{} + permission := []byte(`{ + "directory": "/repo/a", + "payload": { + "type": "permission.asked", + "properties": { + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": ["/repo/app.ts"] + } + } + }`) + if err := DispatchEvent(t.Context(), permission, handler); err != nil { + t.Fatal(err) + } + if len(handler.permissions) != 1 || handler.permissions[0].ID != "perm_1" { + t.Fatalf("permissions = %#v", handler.permissions) + } + if handler.permissions[0].Directory != "/repo/a" { + t.Fatalf("permission directory = %q", handler.permissions[0].Directory) + } + + question := []byte(`{ + "directory": "/repo/a", + "payload": { + "type": "message.part.updated", + "properties": { + "part": { + "type": "tool", + "sessionID": "ses_1", + "messageID": "msg_1", + "callID": "call_1", + "tool": "question", + "state": { + "status": "running", + "metadata": {"externalResultReady": true}, + "input": { + "questions": [{ + "header": "Approach", + "question": "Pick one", + "multiple": false, + "options": [ + {"label": "A", "description": "Small"}, + {"label": "B", "description": "Large"} + ] + }] + } + } + } + } + } + }`) + if err := DispatchEvent(t.Context(), question, handler); err != nil { + t.Fatal(err) + } + if len(handler.questions) != 1 { + t.Fatalf("questions = %#v", handler.questions) + } + got := handler.questions[0] + if got.SessionID != "ses_1" || got.MessageID != "msg_1" || got.CallID != "call_1" { + t.Fatalf("question = %#v", got) + } + if got.Directory != "/repo/a" { + t.Fatalf("question directory = %q", got.Directory) + } + if got.Questions[0].Options[1].Label != "B" { + t.Fatalf("question options = %#v", got.Questions[0].Options) + } +} + +func TestDispatchEventRoutesResolvedPermissionAndQuestion(t *testing.T) { + handler := &fakeEventHandler{} + permission := []byte(`{ + "directory": "/repo/a", + "payload": { + "type": "permission.replied", + "properties": { + "sessionID": "ses_1", + "requestID": "perm_1", + "reply": "once" + } + } + }`) + if err := DispatchEvent(t.Context(), permission, handler); err != nil { + t.Fatal(err) + } + if len(handler.resolvedPermissions) != 1 || handler.resolvedPermissions[0] != (bridge.PermissionResolution{SessionID: "ses_1", RequestID: "perm_1", Directory: "/repo/a"}) { + t.Fatalf("resolved permissions = %#v", handler.resolvedPermissions) + } + + question := []byte(`{ + "directory": "/repo/a", + "payload": { + "type": "message.part.updated", + "properties": { + "part": { + "type": "tool", + "sessionID": "ses_1", + "messageID": "msg_1", + "callID": "call_1", + "tool": "question", + "state": {"status": "completed"} + } + } + } + }`) + if err := DispatchEvent(t.Context(), question, handler); err != nil { + t.Fatal(err) + } + if len(handler.resolvedQuestions) != 1 || handler.resolvedQuestions[0] != (bridge.QuestionResolution{SessionID: "ses_1", MessageID: "msg_1", CallID: "call_1", Directory: "/repo/a"}) { + t.Fatalf("resolved questions = %#v", handler.resolvedQuestions) + } +} + +func TestDispatchEventRoutesSessionCreated(t *testing.T) { + handler := &fakeEventHandler{} + err := DispatchEvent(t.Context(), []byte(`{ + "directory": "/repo/a", + "payload": { + "type": "session.created", + "properties": { + "sessionID": "child_1", + "info": { + "id": "child_1", + "title": "Child session", + "parentID": "root_1" + } + } + } + }`), handler) + if err != nil { + t.Fatal(err) + } + if len(handler.sessions) != 1 || handler.sessions[0] != (bridge.Session{ID: "child_1", Title: "Child session", ParentID: "root_1", Directory: "/repo/a"}) { + t.Fatalf("sessions = %#v", handler.sessions) + } +} + +func TestDispatchEventIgnoresQuestionBeforeExternalResultReady(t *testing.T) { + handler := &fakeEventHandler{} + err := DispatchEvent(t.Context(), []byte(`{ + "payload": { + "type": "message.part.updated", + "properties": { + "part": { + "type": "tool", + "sessionID": "ses_1", + "messageID": "msg_1", + "callID": "call_1", + "tool": "question", + "state": { + "status": "pending", + "input": { + "questions": [{"question": "Pick one"}] + } + } + } + } + } + }`), handler) + if err != nil { + t.Fatal(err) + } + if len(handler.questions) != 0 { + t.Fatalf("questions = %#v", handler.questions) + } +} + +func TestClientStreamsGlobalEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/global/event" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Header().Set("content-type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"sessionID\":\"ses_1\",\"messageID\":\"msg_1\",\"id\":\"prt_1\",\"text\":\"hi\",\"time\":{\"start\":1,\"end\":2}}}}}\n\n")) + })) + defer server.Close() + + handler := &fakeEventHandler{} + if err := New(server.URL).StreamEvents(t.Context(), handler); err != nil { + t.Fatal(err) + } + if len(handler.texts) != 1 || handler.texts[0].text != "hi" { + t.Fatalf("texts = %#v", handler.texts) + } +} + +func TestClientRejectsNonSSEEventStreamBeforeReady(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/global/event" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + handler := &streamReadyEventHandler{} + if err := New(server.URL).StreamEvents(t.Context(), handler); err == nil { + t.Fatal("expected non-SSE stream response to fail") + } + if handler.ready != 0 { + t.Fatalf("ready calls = %d", handler.ready) + } +} + +func TestClientContinuesAfterEventHandlerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"sessionID\":\"ses_1\",\"text\":\"first\",\"time\":{\"end\":1}}}}}\n\n")) + _, _ = w.Write([]byte("data: {\"payload\":{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"sessionID\":\"ses_1\",\"text\":\"second\",\"time\":{\"end\":2}}}}}\n\n")) + })) + defer server.Close() + + handler := &fakeEventHandler{textErr: errors.New("send failed")} + if err := New(server.URL).StreamEvents(t.Context(), handler); err != nil { + t.Fatal(err) + } + if len(handler.texts) != 2 { + t.Fatalf("texts = %#v", handler.texts) + } +} + +func TestClientStreamsLongCompletedText(t *testing.T) { + longText := strings.Repeat("x", 70*1024) + envelope := map[string]any{ + "payload": map[string]any{ + "type": "message.part.updated", + "properties": map[string]any{ + "part": map[string]any{ + "type": "text", + "sessionID": "ses_1", + "messageID": "msg_1", + "id": "prt_1", + "text": longText, + "time": map[string]any{"start": 1, "end": 2}, + }, + }, + }, + } + data, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "text/event-stream") + _, _ = w.Write([]byte("data: ")) + _, _ = w.Write(data) + _, _ = w.Write([]byte("\n\n")) + })) + defer server.Close() + + handler := &fakeEventHandler{} + if err := New(server.URL).StreamEvents(t.Context(), handler); err != nil { + t.Fatal(err) + } + if len(handler.texts) != 1 || handler.texts[0].text != longText { + t.Fatalf("texts = %#v", handler.texts) + } +} diff --git a/packages/remote-bridge/internal/platforms/platforms.go b/packages/remote-bridge/internal/platforms/platforms.go new file mode 100644 index 000000000..c8706baf7 --- /dev/null +++ b/packages/remote-bridge/internal/platforms/platforms.go @@ -0,0 +1,25 @@ +package platforms + +import ( + "sort" + + "github.com/chenhg5/cc-connect/core" + _ "github.com/chenhg5/cc-connect/platform/dingtalk" + _ "github.com/chenhg5/cc-connect/platform/discord" + _ "github.com/chenhg5/cc-connect/platform/feishu" + _ "github.com/chenhg5/cc-connect/platform/line" + _ "github.com/chenhg5/cc-connect/platform/max" + _ "github.com/chenhg5/cc-connect/platform/qq" + _ "github.com/chenhg5/cc-connect/platform/qqbot" + _ "github.com/chenhg5/cc-connect/platform/slack" + _ "github.com/chenhg5/cc-connect/platform/telegram" + _ "github.com/chenhg5/cc-connect/platform/wecom" + _ "github.com/chenhg5/cc-connect/platform/weixin" + _ "github.com/chenhg5/cc-connect/platform/wps-xiezuo" +) + +func Available() []string { + names := core.ListRegisteredPlatforms() + sort.Strings(names) + return names +} diff --git a/packages/remote-bridge/internal/platforms/platforms_test.go b/packages/remote-bridge/internal/platforms/platforms_test.go new file mode 100644 index 000000000..9c7e255ff --- /dev/null +++ b/packages/remote-bridge/internal/platforms/platforms_test.go @@ -0,0 +1,64 @@ +package platforms + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestAvailableIncludesCCConnectPlatforms(t *testing.T) { + got := map[string]bool{} + for _, name := range Available() { + got[name] = true + } + for _, name := range []string{ + "dingtalk", + "discord", + "feishu", + "lark", + "line", + "max", + "qq", + "qqbot", + "slack", + "telegram", + "wecom", + "weixin", + "wps-xiezuo", + } { + if !got[name] { + t.Fatalf("platform %q not registered; got %v", name, Available()) + } + } + if got["weibo"] { + t.Fatalf("weibo should not be exposed until it can reconstruct reply contexts; got %v", Available()) + } +} + +func TestAvailablePlatformsCanReconstructReplyContexts(t *testing.T) { + options := map[string]map[string]any{ + "dingtalk": {"client_id": "client", "client_secret": "secret", "allow_from": "conv"}, + "discord": {"token": "token", "allow_from": "channel"}, + "feishu": {"app_id": "app", "app_secret": "secret", "allow_chat": "oc_chat", "group_only": true}, + "lark": {"app_id": "app", "app_secret": "secret", "allow_chat": "oc_chat", "group_only": true}, + "line": {"channel_secret": "secret", "channel_token": "token", "allow_from": "target"}, + "max": {"token": "token", "allow_from": "chat"}, + "qq": {"allow_from": "user"}, + "qqbot": {"app_id": "app", "app_secret": "secret", "allow_from": "channel"}, + "slack": {"bot_token": "xoxb-token", "app_token": "xapp-token", "allow_from": "channel"}, + "telegram": {"token": "token", "allow_from": "chat"}, + "wecom": {"mode": "websocket", "bot_id": "bot", "bot_secret": "secret", "allow_from": "chat"}, + "weixin": {"token": "token", "allow_from": "user"}, + "wps-xiezuo": {"app_id": "app", "app_secret": "secret", "allow_from": "chat"}, + } + + for _, name := range Available() { + platform, err := core.CreatePlatform(name, options[name]) + if err != nil { + t.Fatalf("create platform %q: %v", name, err) + } + if _, ok := platform.(core.ReplyContextReconstructor); !ok { + t.Fatalf("platform %q is exposed but cannot reconstruct reply contexts", name) + } + } +} From e13d0e714e141647fc6eb17a667fa5dcbaf1bc03 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 19:03:32 +0800 Subject: [PATCH 02/24] refactor(remote-bridge): cut redundant structure per Occam-razor review Behavior-preserving simplifications surfaced by two independent reviews. Full test suite + race detector green; per-package coverage unchanged. - session_pointers: merge Memory/File dual impl into one store with an optional path (saveLocked is a no-op when path is empty) - engine: collapse permissionOrder/questionOrder/blockerOrder into a single blockerOrder; derive oldest-of-kind by scanning it directly - engine: drop the unreachable "Unknown command" branch; handleCommand now returns (handled, err) and isRemoteCommand is gone - engine: inline the single-field Prompt wrapper to a plain string arg - engine: slices.DeleteFunc for blocker filtering; inline single-use Handle*Resolved forwards and pendingBlocker alias locals - gateway: defer stopPlatforms once instead of repeating it on every return - client: drop the ListQuestions JSON marshal round-trip - events: one flush closure for the parseSSE dispatch path Net -187 lines. --- .../remote-bridge/internal/bridge/engine.go | 255 ++++++------------ .../internal/bridge/engine_test.go | 4 +- .../internal/bridge/session_pointers.go | 187 ++++--------- .../remote-bridge/internal/gateway/gateway.go | 9 +- .../internal/gateway/gateway_test.go | 2 +- .../remote-bridge/internal/pawwork/client.go | 16 +- .../internal/pawwork/client_test.go | 4 +- .../remote-bridge/internal/pawwork/events.go | 52 ++-- 8 files changed, 171 insertions(+), 358 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 0fb714c22..dcef41d40 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -3,6 +3,7 @@ package bridge import ( "context" "fmt" + "slices" "strconv" "strings" "sync" @@ -10,10 +11,6 @@ import ( "github.com/chenhg5/cc-connect/core" ) -type Prompt struct { - Text string -} - type Session struct { ID string Title string @@ -69,7 +66,7 @@ type QuestionOption struct { type Sidecar interface { CreateSession(context.Context) (string, error) - SendPrompt(context.Context, string, Prompt) error + SendPrompt(context.Context, string, string) error ListSessions(context.Context, int) ([]Session, error) AbortSession(context.Context, string) (bool, error) ReplyPermission(context.Context, PendingPermission, PermissionReply) error @@ -90,17 +87,15 @@ type EventCursorStore interface { } type Engine struct { - mu sync.Mutex - sidecar Sidecar - pointers SessionPointers - pickers map[string][]Session - active map[string]delivery - platforms map[string]core.Platform - permissions map[string]PendingPermission - permissionOrder []string - questions map[string]PendingQuestion - questionOrder []string - blockerOrder []blockerRef + mu sync.Mutex + sidecar Sidecar + pointers SessionPointers + pickers map[string][]Session + active map[string]delivery + platforms map[string]core.Platform + permissions map[string]PendingPermission + questions map[string]PendingQuestion + blockerOrder []blockerRef } type blockerKind string @@ -174,7 +169,6 @@ func (e *Engine) SetPendingPermission(permission PendingPermission) { e.mu.Lock() defer e.mu.Unlock() if _, ok := e.permissions[key]; !ok { - e.permissionOrder = append(e.permissionOrder, key) e.blockerOrder = append(e.blockerOrder, blockerRef{kind: permissionBlocker, key: key}) } e.permissions[key] = permission @@ -188,7 +182,6 @@ func (e *Engine) SetPendingQuestion(question PendingQuestion) { e.mu.Lock() defer e.mu.Unlock() if _, ok := e.questions[key]; !ok { - e.questionOrder = append(e.questionOrder, key) e.blockerOrder = append(e.blockerOrder, blockerRef{kind: questionBlocker, key: key}) } e.questions[key] = question @@ -200,8 +193,8 @@ func (e *Engine) HandleMessage(ctx context.Context, platform core.Platform, msg return nil } key := remoteKey(platform, msg) - if isRemoteCommand(text) { - return e.handleCommand(ctx, platform, msg, key, text) + if handled, err := e.handleCommand(ctx, platform, msg, key, text); handled || err != nil { + return err } sessionID, err := e.ensureSession(ctx, key) if err != nil { @@ -211,7 +204,7 @@ func (e *Engine) HandleMessage(ctx context.Context, platform core.Platform, msg if handled, err := e.handlePendingReply(ctx, platform, msg, sessionID, text); handled || err != nil { return err } - if err := e.sidecar.SendPrompt(ctx, sessionID, Prompt{Text: text}); err != nil { + if err := e.sidecar.SendPrompt(ctx, sessionID, text); err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not send the message: "+err.Error()) return err } @@ -241,12 +234,39 @@ func (e *Engine) HandleQuestion(ctx context.Context, question PendingQuestion) e } func (e *Engine) HandlePermissionResolved(_ context.Context, resolution PermissionResolution) error { - e.clearResolvedPermission(resolution) + e.mu.Lock() + defer e.mu.Unlock() + if resolution.RequestID != "" { + e.clearPermissionKeyLocked(resolution.RequestID) + return nil + } + if resolution.SessionID != "" { + e.clearPermissionsLocked(func(permission PendingPermission) bool { + return permission.SessionID == resolution.SessionID + }) + } return nil } func (e *Engine) HandleQuestionResolved(_ context.Context, resolution QuestionResolution) error { - e.clearResolvedQuestion(resolution) + e.mu.Lock() + defer e.mu.Unlock() + if resolution.CallID != "" || resolution.MessageID != "" { + for key, question := range e.questions { + callMatches := resolution.CallID == "" || question.CallID == resolution.CallID + messageMatches := resolution.MessageID == "" || question.MessageID == resolution.MessageID + if callMatches && messageMatches { + e.clearQuestionKeyLocked(key) + return nil + } + } + return nil + } + if resolution.SessionID != "" { + e.clearQuestionsLocked(func(question PendingQuestion) bool { + return question.SessionID == resolution.SessionID + }) + } return nil } @@ -329,83 +349,72 @@ func (e *Engine) handlePendingReply( return false, nil } if blocker.kind == permissionBlocker { - permission := blocker.permission reply := permissionReplyForText(text) if reply == "" { return true, platform.Reply(ctx, msg.ReplyCtx, "Reply yes, always, or no.") } - if err := e.sidecar.ReplyPermission(ctx, permission, PermissionReply{Reply: reply}); err != nil { + if err := e.sidecar.ReplyPermission(ctx, blocker.permission, PermissionReply{Reply: reply}); err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not answer the permission request: "+err.Error()) return true, err } - e.clearPendingPermission(permission) + e.clearPendingPermission(blocker.permission) return true, nil } if blocker.kind == questionBlocker { - question := blocker.question - answers, err := answersForQuestionText(question, text) + answers, err := answersForQuestionText(blocker.question, text) if err != nil { return true, platform.Reply(ctx, msg.ReplyCtx, err.Error()) } - if err := e.sidecar.SubmitQuestion(ctx, question, answers); err != nil { + if err := e.sidecar.SubmitQuestion(ctx, blocker.question, answers); err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not submit the answer: "+err.Error()) return true, err } - e.clearPendingQuestion(question) + e.clearPendingQuestion(blocker.question) return true, nil } return false, nil } -func isRemoteCommand(text string) bool { - name, _, _ := strings.Cut(text, " ") - switch name { - case "/new", "/sessions", "/stop", "/help": - return true - default: - return false - } -} - func (e *Engine) pendingBlocker(sessionID string) (pendingBlocker, bool) { e.mu.Lock() defer e.mu.Unlock() root := e.pointers.RootSession(sessionID) + var kind blockerKind + found := false for index := len(e.blockerOrder) - 1; index >= 0; index-- { - ref := e.blockerOrder[index] - switch ref.kind { + if ref := e.blockerOrder[index]; e.blockerLiveForRootLocked(ref, root) { + kind = ref.kind + found = true + break + } + } + if !found { + return pendingBlocker{}, false + } + for _, ref := range e.blockerOrder { + if ref.kind != kind || !e.blockerLiveForRootLocked(ref, root) { + continue + } + switch kind { case permissionBlocker: - permission, ok := e.permissions[ref.key] - if !ok || e.pointers.RootSession(permission.SessionID) != root { - continue - } - if pending, ok := e.pendingPermissionLocked(root); ok { - return pendingBlocker{kind: permissionBlocker, permission: pending}, true - } + return pendingBlocker{kind: permissionBlocker, permission: e.permissions[ref.key]}, true case questionBlocker: - question, ok := e.questions[ref.key] - if !ok || e.pointers.RootSession(question.SessionID) != root { - continue - } - if pending, ok := e.pendingQuestionLocked(root); ok { - return pendingBlocker{kind: questionBlocker, question: pending}, true - } + return pendingBlocker{kind: questionBlocker, question: e.questions[ref.key]}, true } } return pendingBlocker{}, false } -func (e *Engine) pendingPermissionLocked(root string) (PendingPermission, bool) { - for _, key := range e.permissionOrder { - permission, ok := e.permissions[key] - if !ok { - continue - } - if e.pointers.RootSession(permission.SessionID) == root { - return permission, true - } +func (e *Engine) blockerLiveForRootLocked(ref blockerRef, root string) bool { + switch ref.kind { + case permissionBlocker: + permission, ok := e.permissions[ref.key] + return ok && e.pointers.RootSession(permission.SessionID) == root + case questionBlocker: + question, ok := e.questions[ref.key] + return ok && e.pointers.RootSession(question.SessionID) == root } - return PendingPermission{}, false + return false } func (e *Engine) clearPendingPermission(permission PendingPermission) { @@ -414,25 +423,8 @@ func (e *Engine) clearPendingPermission(permission PendingPermission) { e.clearPermissionKeyLocked(permissionKey(permission)) } -func (e *Engine) clearResolvedPermission(resolution PermissionResolution) { - e.mu.Lock() - defer e.mu.Unlock() - if resolution.RequestID != "" { - e.clearPermissionKeyLocked(resolution.RequestID) - return - } - if resolution.SessionID != "" { - e.clearPermissionsLocked(func(permission PendingPermission) bool { - return permission.SessionID == resolution.SessionID - }) - } -} - func (e *Engine) clearPermissionKeyLocked(key string) { delete(e.permissions, key) - e.clearPermissionOrderLocked(func(current string) bool { - return current == key - }) e.clearBlockerOrderLocked(func(current blockerRef) bool { return current.kind == permissionBlocker && current.key == key }) @@ -444,10 +436,6 @@ func (e *Engine) clearPermissionsLocked(match func(PendingPermission) bool) { delete(e.permissions, key) } } - e.clearPermissionOrderLocked(func(key string) bool { - _, ok := e.permissions[key] - return !ok - }) e.clearBlockerOrderLocked(func(current blockerRef) bool { if current.kind != permissionBlocker { return false @@ -457,61 +445,14 @@ func (e *Engine) clearPermissionsLocked(match func(PendingPermission) bool) { }) } -func (e *Engine) clearPermissionOrderLocked(match func(string) bool) { - next := e.permissionOrder[:0] - for _, key := range e.permissionOrder { - if !match(key) { - next = append(next, key) - } - } - e.permissionOrder = next -} - -func (e *Engine) pendingQuestionLocked(root string) (PendingQuestion, bool) { - for _, key := range e.questionOrder { - question, ok := e.questions[key] - if !ok { - continue - } - if e.pointers.RootSession(question.SessionID) == root { - return question, true - } - } - return PendingQuestion{}, false -} - func (e *Engine) clearPendingQuestion(question PendingQuestion) { e.mu.Lock() defer e.mu.Unlock() e.clearQuestionKeyLocked(questionKey(question)) } -func (e *Engine) clearResolvedQuestion(resolution QuestionResolution) { - e.mu.Lock() - defer e.mu.Unlock() - if resolution.CallID != "" || resolution.MessageID != "" { - for key, question := range e.questions { - callMatches := resolution.CallID == "" || question.CallID == resolution.CallID - messageMatches := resolution.MessageID == "" || question.MessageID == resolution.MessageID - if callMatches && messageMatches { - e.clearQuestionKeyLocked(key) - return - } - } - return - } - if resolution.SessionID != "" { - e.clearQuestionsLocked(func(question PendingQuestion) bool { - return question.SessionID == resolution.SessionID - }) - } -} - func (e *Engine) clearQuestionKeyLocked(key string) { delete(e.questions, key) - e.clearQuestionOrderLocked(func(current string) bool { - return current == key - }) e.clearBlockerOrderLocked(func(current blockerRef) bool { return current.kind == questionBlocker && current.key == key }) @@ -523,10 +464,6 @@ func (e *Engine) clearQuestionsLocked(match func(PendingQuestion) bool) { delete(e.questions, key) } } - e.clearQuestionOrderLocked(func(key string) bool { - _, ok := e.questions[key] - return !ok - }) e.clearBlockerOrderLocked(func(current blockerRef) bool { if current.kind != questionBlocker { return false @@ -536,65 +473,49 @@ func (e *Engine) clearQuestionsLocked(match func(PendingQuestion) bool) { }) } -func (e *Engine) clearQuestionOrderLocked(match func(string) bool) { - next := e.questionOrder[:0] - for _, key := range e.questionOrder { - if !match(key) { - next = append(next, key) - } - } - e.questionOrder = next -} - func (e *Engine) clearBlockerOrderLocked(match func(blockerRef) bool) { - next := e.blockerOrder[:0] - for _, current := range e.blockerOrder { - if !match(current) { - next = append(next, current) - } - } - e.blockerOrder = next + e.blockerOrder = slices.DeleteFunc(e.blockerOrder, match) } -func (e *Engine) handleCommand(ctx context.Context, platform core.Platform, msg *core.Message, key string, text string) error { +func (e *Engine) handleCommand(ctx context.Context, platform core.Platform, msg *core.Message, key string, text string) (bool, error) { name, arg, _ := strings.Cut(text, " ") switch name { case "/new": sessionID, err := e.sidecar.CreateSession(ctx) if err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not start a session: "+err.Error()) - return err + return true, err } if err := e.setCurrent(key, sessionID); err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not remember the session: "+err.Error()) - return err + return true, err } e.setActive(sessionID, platform, msg.ReplyCtx) - return platform.Reply(ctx, msg.ReplyCtx, "Started a new PawWork session.") + return true, platform.Reply(ctx, msg.ReplyCtx, "Started a new PawWork session.") case "/sessions": arg = strings.TrimSpace(arg) if arg == "" { - return e.replySessionPicker(ctx, platform, msg, key) + return true, e.replySessionPicker(ctx, platform, msg, key) } - return e.switchSession(ctx, platform, msg, key, arg) + return true, e.switchSession(ctx, platform, msg, key, arg) case "/stop": sessionID := e.CurrentSession(key) if sessionID == "" { - return platform.Reply(ctx, msg.ReplyCtx, "No active PawWork session.") + return true, platform.Reply(ctx, msg.ReplyCtx, "No active PawWork session.") } aborted, err := e.sidecar.AbortSession(ctx, sessionID) if err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not stop the run: "+err.Error()) - return err + return true, err } if aborted { - return platform.Reply(ctx, msg.ReplyCtx, "Stopped the current PawWork run.") + return true, platform.Reply(ctx, msg.ReplyCtx, "Stopped the current PawWork run.") } - return platform.Reply(ctx, msg.ReplyCtx, "No running PawWork run.") + return true, platform.Reply(ctx, msg.ReplyCtx, "No running PawWork run.") case "/help": - return platform.Reply(ctx, msg.ReplyCtx, "Commands: /new, /sessions, /sessions N, /stop.") + return true, platform.Reply(ctx, msg.ReplyCtx, "Commands: /new, /sessions, /sessions N, /stop.") default: - return platform.Reply(ctx, msg.ReplyCtx, "Unknown command. Try /help.") + return false, nil } } diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index 9caee0952..bbd6351df 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -41,8 +41,8 @@ func (f *fakeSidecar) CreateSession(context.Context) (string, error) { return id, nil } -func (f *fakeSidecar) SendPrompt(_ context.Context, sessionID string, prompt Prompt) error { - f.prompts = append(f.prompts, sentPrompt{sessionID: sessionID, text: prompt.Text}) +func (f *fakeSidecar) SendPrompt(_ context.Context, sessionID string, text string) error { + f.prompts = append(f.prompts, sentPrompt{sessionID: sessionID, text: text}) return nil } diff --git a/packages/remote-bridge/internal/bridge/session_pointers.go b/packages/remote-bridge/internal/bridge/session_pointers.go index 1c7a8331a..cf1231c1f 100644 --- a/packages/remote-bridge/internal/bridge/session_pointers.go +++ b/packages/remote-bridge/internal/bridge/session_pointers.go @@ -9,107 +9,23 @@ import ( "sync" ) -type MemorySessionPointers struct { +// SessionPointersStore maps remote conversations to PawWork sessions and tracks +// the event cursor. With a non-empty path it persists to disk; an empty path +// keeps everything in memory. +type SessionPointersStore struct { mu sync.Mutex + path string sessions map[string]string parents map[string]string eventCursor string } -func NewMemorySessionPointers() *MemorySessionPointers { - return &MemorySessionPointers{ - sessions: make(map[string]string), - parents: make(map[string]string), - } -} - -func (p *MemorySessionPointers) Get(remoteKey string) string { - p.mu.Lock() - defer p.mu.Unlock() - return p.sessions[remoteKey] -} - -func (p *MemorySessionPointers) Set(remoteKey string, sessionID string) error { - p.mu.Lock() - defer p.mu.Unlock() - if p.hasRootConflictLocked(remoteKey, sessionID) { - return fmt.Errorf("session root is already bound to another remote conversation") - } - p.sessions[remoteKey] = sessionID - return nil -} - -func (p *MemorySessionPointers) SetParent(sessionID string, parentID string) error { - if sessionID == "" || parentID == "" { - return nil - } - p.mu.Lock() - defer p.mu.Unlock() - if hasAnyRootConflict(p.sessions, withParent(p.parents, sessionID, parentID)) { - return fmt.Errorf("session root is already bound to another remote conversation") - } - p.parents[sessionID] = parentID - return nil -} - -func (p *MemorySessionPointers) RemoteKeyForSession(sessionID string) string { - p.mu.Lock() - defer p.mu.Unlock() - keys := p.remoteKeysForRootLocked(p.rootLocked(sessionID)) - if len(keys) != 1 { - return "" - } - return keys[0] -} - -func (p *MemorySessionPointers) RootSession(sessionID string) string { - p.mu.Lock() - defer p.mu.Unlock() - return p.rootLocked(sessionID) -} - -func (p *MemorySessionPointers) EventCursor() string { - p.mu.Lock() - defer p.mu.Unlock() - return p.eventCursor -} - -func (p *MemorySessionPointers) SetEventCursor(cursor string) error { - if cursor == "" { - return nil - } - p.mu.Lock() - defer p.mu.Unlock() - p.eventCursor = cursor - return nil -} - -func (p *MemorySessionPointers) rootLocked(sessionID string) string { - return rootSession(p.parents, sessionID) -} - -func (p *MemorySessionPointers) hasRootConflictLocked(remoteKey string, sessionID string) bool { - return hasRootConflict(p.sessions, p.parents, remoteKey, sessionID) -} - -func (p *MemorySessionPointers) remoteKeysForRootLocked(root string) []string { - return remoteKeysForRoot(p.sessions, p.parents, root) -} - -type FileSessionPointers struct { - mu sync.Mutex - path string - sessions map[string]string - parents map[string]string - eventCursor string +func NewMemorySessionPointers() *SessionPointersStore { + return newSessionPointers("") } -func NewFileSessionPointers(path string) (*FileSessionPointers, error) { - pointers := &FileSessionPointers{ - path: path, - sessions: make(map[string]string), - parents: make(map[string]string), - } +func NewFileSessionPointers(path string) (*SessionPointersStore, error) { + pointers := newSessionPointers(path) data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return pointers, nil @@ -141,23 +57,31 @@ func NewFileSessionPointers(path string) (*FileSessionPointers, error) { return pointers, nil } -func (p *FileSessionPointers) Get(remoteKey string) string { +func newSessionPointers(path string) *SessionPointersStore { + return &SessionPointersStore{ + path: path, + sessions: make(map[string]string), + parents: make(map[string]string), + } +} + +func (p *SessionPointersStore) Get(remoteKey string) string { p.mu.Lock() defer p.mu.Unlock() return p.sessions[remoteKey] } -func (p *FileSessionPointers) Set(remoteKey string, sessionID string) error { +func (p *SessionPointersStore) Set(remoteKey string, sessionID string) error { p.mu.Lock() defer p.mu.Unlock() - if p.hasRootConflictLocked(remoteKey, sessionID) { + if hasRootConflict(p.sessions, p.parents, remoteKey, sessionID) { return fmt.Errorf("session root is already bound to another remote conversation") } p.sessions[remoteKey] = sessionID return p.saveLocked() } -func (p *FileSessionPointers) SetParent(sessionID string, parentID string) error { +func (p *SessionPointersStore) SetParent(sessionID string, parentID string) error { if sessionID == "" || parentID == "" { return nil } @@ -170,29 +94,29 @@ func (p *FileSessionPointers) SetParent(sessionID string, parentID string) error return p.saveLocked() } -func (p *FileSessionPointers) RemoteKeyForSession(sessionID string) string { +func (p *SessionPointersStore) RemoteKeyForSession(sessionID string) string { p.mu.Lock() defer p.mu.Unlock() - keys := p.remoteKeysForRootLocked(p.rootLocked(sessionID)) + keys := remoteKeysForRoot(p.sessions, p.parents, rootSession(p.parents, sessionID)) if len(keys) != 1 { return "" } return keys[0] } -func (p *FileSessionPointers) RootSession(sessionID string) string { +func (p *SessionPointersStore) RootSession(sessionID string) string { p.mu.Lock() defer p.mu.Unlock() - return p.rootLocked(sessionID) + return rootSession(p.parents, sessionID) } -func (p *FileSessionPointers) EventCursor() string { +func (p *SessionPointersStore) EventCursor() string { p.mu.Lock() defer p.mu.Unlock() return p.eventCursor } -func (p *FileSessionPointers) SetEventCursor(cursor string) error { +func (p *SessionPointersStore) SetEventCursor(cursor string) error { if cursor == "" { return nil } @@ -205,16 +129,30 @@ func (p *FileSessionPointers) SetEventCursor(cursor string) error { return p.saveLocked() } -func (p *FileSessionPointers) rootLocked(sessionID string) string { - return rootSession(p.parents, sessionID) -} - -func (p *FileSessionPointers) hasRootConflictLocked(remoteKey string, sessionID string) bool { - return hasRootConflict(p.sessions, p.parents, remoteKey, sessionID) -} - -func (p *FileSessionPointers) remoteKeysForRootLocked(root string) []string { - return remoteKeysForRoot(p.sessions, p.parents, root) +func (p *SessionPointersStore) saveLocked() error { + if p.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(p.path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(struct { + Sessions map[string]string `json:"sessions"` + Parents map[string]string `json:"parents"` + EventCursor string `json:"eventCursor,omitempty"` + }{ + Sessions: p.sessions, + Parents: p.parents, + EventCursor: p.eventCursor, + }, "", " ") + if err != nil { + return err + } + tempPath := p.path + ".tmp" + if err := os.WriteFile(tempPath, data, 0o600); err != nil { + return err + } + return os.Rename(tempPath, p.path) } func rootSession(parents map[string]string, sessionID string) string { @@ -283,26 +221,3 @@ func remoteKeysForRoot(sessions map[string]string, parents map[string]string, ro } return keys } - -func (p *FileSessionPointers) saveLocked() error { - if err := os.MkdirAll(filepath.Dir(p.path), 0o700); err != nil { - return err - } - data, err := json.MarshalIndent(struct { - Sessions map[string]string `json:"sessions"` - Parents map[string]string `json:"parents"` - EventCursor string `json:"eventCursor,omitempty"` - }{ - Sessions: p.sessions, - Parents: p.parents, - EventCursor: p.eventCursor, - }, "", " ") - if err != nil { - return err - } - tempPath := p.path + ".tmp" - if err := os.WriteFile(tempPath, data, 0o600); err != nil { - return err - } - return os.Rename(tempPath, p.path) -} diff --git a/packages/remote-bridge/internal/gateway/gateway.go b/packages/remote-bridge/internal/gateway/gateway.go index fddbfd628..e1c21f625 100644 --- a/packages/remote-bridge/internal/gateway/gateway.go +++ b/packages/remote-bridge/internal/gateway/gateway.go @@ -135,6 +135,7 @@ func (a *App) PlatformNames() []string { func (a *App) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) + defer a.stopPlatforms() defer cancel() errCh := make(chan error, len(a.platforms)+1) streamReady := make(chan struct{}) @@ -163,16 +164,11 @@ func (a *App) Run(ctx context.Context) error { select { case <-streamReady: case <-ctx.Done(): - a.stopPlatforms() return nil case err := <-errCh: - cancel() - a.stopPlatforms() return err } if err := a.hydrate(ctx); err != nil { - cancel() - a.stopPlatforms() return err } for _, platform := range a.platforms { @@ -186,11 +182,8 @@ func (a *App) Run(ctx context.Context) error { select { case <-ctx.Done(): - a.stopPlatforms() return nil case err := <-errCh: - cancel() - a.stopPlatforms() return err } } diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go index cf6bf3215..baf07776e 100644 --- a/packages/remote-bridge/internal/gateway/gateway_test.go +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -511,7 +511,7 @@ type failingSidecar struct{} func (failingSidecar) CreateSession(context.Context) (string, error) { return "", errors.New("sidecar unavailable") } -func (failingSidecar) SendPrompt(context.Context, string, bridge.Prompt) error { +func (failingSidecar) SendPrompt(context.Context, string, string) error { return errors.New("sidecar unavailable") } func (failingSidecar) ListSessions(context.Context, int) ([]bridge.Session, error) { diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index 9160340fd..d2a770c22 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -102,11 +102,11 @@ func (c *Client) CreateSession(ctx context.Context) (string, error) { return session.ID, nil } -func (c *Client) SendPrompt(ctx context.Context, sessionID string, prompt bridge.Prompt) error { +func (c *Client) SendPrompt(ctx context.Context, sessionID string, text string) error { body := map[string]any{ "parts": []map[string]string{{ "type": "text", - "text": prompt.Text, + "text": text, }}, } return c.doSessionJSON(ctx, sessionID, http.MethodPost, "/session/"+url.PathEscape(sessionID)+"/prompt_async", body, nil) @@ -218,9 +218,7 @@ func (c *Client) ListPermissions(ctx context.Context) ([]bridge.PendingPermissio func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, error) { questions := []bridge.PendingQuestion{} for _, directory := range c.knownDirectories() { - var raw []struct { - Part json.RawMessage `json:"part"` - } + var raw []json.RawMessage if err := c.doJSONWithDirectory(ctx, directory, http.MethodGet, "/external-result", nil, &raw); err != nil { if !canSkipHydrationDirectoryError(err) { return nil, err @@ -228,13 +226,7 @@ func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, e slog.Warn("remote bridge could not list questions", "directory", directory, "error", err) continue } - for _, item := range raw { - data, err := json.Marshal(struct { - Part json.RawMessage `json:"part"` - }{Part: item.Part}) - if err != nil { - return nil, err - } + for _, data := range raw { question, ok, _, _, err := questionUpdateFromEvent(data, directory) if err != nil { return nil, err diff --git a/packages/remote-bridge/internal/pawwork/client_test.go b/packages/remote-bridge/internal/pawwork/client_test.go index 12a8929ee..f5dc67f48 100644 --- a/packages/remote-bridge/internal/pawwork/client_test.go +++ b/packages/remote-bridge/internal/pawwork/client_test.go @@ -60,7 +60,7 @@ func TestClientUsesPawWorkSessionEndpoints(t *testing.T) { t.Fatalf("sessions = %#v", sessions) } - if err := client.SendPrompt(t.Context(), "ses_a", bridge.Prompt{Text: "hello"}); err != nil { + if err := client.SendPrompt(t.Context(), "ses_a", "hello"); err != nil { t.Fatal(err) } if got := promptBody["parts"]; got == nil { @@ -98,7 +98,7 @@ func TestClientFetchesSessionDirectoryWhenMissing(t *testing.T) { defer server.Close() client := New(server.URL) - if err := client.SendPrompt(t.Context(), "ses_unknown", bridge.Prompt{Text: "hello"}); err != nil { + if err := client.SendPrompt(t.Context(), "ses_unknown", "hello"); err != nil { t.Fatal(err) } if promptDirectory != "/repo/unknown" { diff --git a/packages/remote-bridge/internal/pawwork/events.go b/packages/remote-bridge/internal/pawwork/events.go index 5d72fdaa9..7e71bf2c1 100644 --- a/packages/remote-bridge/internal/pawwork/events.go +++ b/packages/remote-bridge/internal/pawwork/events.go @@ -197,27 +197,33 @@ func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLa scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) var data strings.Builder var eventID string + flush := func() error { + if data.Len() > 0 { + if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { + var refresh replayRefreshError + if errors.As(err, &refresh) { + return err + } + slog.Warn("remote bridge ignored event", "error", err) + } + data.Reset() + } + if eventID != "" { + if err := setLastEventID(eventID); err != nil { + return err + } + eventID = "" + } + return nil + } for scanner.Scan() { if err := ctx.Err(); err != nil { return err } line := scanner.Text() if line == "" { - if data.Len() > 0 { - if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { - var refresh replayRefreshError - if errors.As(err, &refresh) { - return err - } - slog.Warn("remote bridge ignored event", "error", err) - } - data.Reset() - } - if eventID != "" { - if err := setLastEventID(eventID); err != nil { - return err - } - eventID = "" + if err := flush(); err != nil { + return err } continue } @@ -238,21 +244,7 @@ func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLa if err := scanner.Err(); err != nil { return err } - if data.Len() > 0 { - if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { - var refresh replayRefreshError - if errors.As(err, &refresh) { - return err - } - slog.Warn("remote bridge ignored event", "error", err) - } - if eventID != "" { - if err := setLastEventID(eventID); err != nil { - return err - } - } - } - return nil + return flush() } type assistantText struct { From 5dd39cda8e53bfac14c3e6e18c7474e3b10ccda4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 19:15:08 +0800 Subject: [PATCH 03/24] fix(remote-bridge): make multi-question/multi-select replies idiot-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P3. The question prompt only said "Reply with a number or answer text", but the parser requires one line per question (multi-question) and comma-separated numbers (multi-select) — users couldn't know the format. - questionReplyHint: prompt now states the exact format per question shape (single / multi-select / multi-question), with an example - accept full-width "," and ideographic "、" commas in multi-select replies so replies typed on a Chinese keyboard parse like ASCII ones - tests for the per-shape hint text and the comma variants Single-question prompt text unchanged. --- .../remote-bridge/internal/bridge/engine.go | 37 +++++++++++++++- .../internal/bridge/engine_test.go | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index dcef41d40..84c59dfca 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -651,10 +651,32 @@ func questionPrompt(pending PendingQuestion) string { } } } - out.WriteString("\n\nReply with a number or answer text.") + out.WriteString("\n\n") + out.WriteString(questionReplyHint(pending.Questions)) return out.String() } +func questionReplyHint(questions []Question) string { + multiQuestion := len(questions) > 1 + multiSelect := false + for _, question := range questions { + if question.Multiple { + multiSelect = true + break + } + } + switch { + case multiQuestion && multiSelect: + return "Reply with one line per question, in order. For a question that allows several choices, separate the numbers with commas (for example: 1,3)." + case multiQuestion: + return "Reply with one line per question, in order: a number or the answer text on each line." + case multiSelect: + return "Reply with the numbers separated by commas (for example: 1,3)." + default: + return "Reply with a number or answer text." + } +} + func permissionReplyForText(text string) string { switch strings.ToLower(strings.TrimSpace(text)) { case "yes", "y", "allow", "ok": @@ -692,7 +714,7 @@ func answerRowForQuestion(text string, question Question) []string { if !question.Multiple { return []string{answerTokenForQuestion(text, question)} } - parts := strings.Split(text, ",") + parts := strings.FieldsFunc(text, isAnswerSeparator) answers := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { @@ -702,6 +724,17 @@ func answerRowForQuestion(text string, question Question) []string { return answers } +// isAnswerSeparator reports whether r separates choices in a multi-select reply. +// Accepts the ASCII comma plus the full-width and ideographic commas so replies +// typed on a Chinese keyboard ("1,3" / "1、3") parse the same as ASCII ones. +func isAnswerSeparator(r rune) bool { + switch r { + case ',', ',', '、': + return true + } + return false +} + func answerTokenForQuestion(text string, question Question) string { if index, err := strconv.Atoi(text); err == nil && index >= 1 && index <= len(question.Options) { return question.Options[index-1].Label diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index bbd6351df..d57c5c393 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -3,6 +3,7 @@ package bridge import ( "context" "path/filepath" + "strings" "testing" "github.com/chenhg5/cc-connect/core" @@ -646,6 +647,49 @@ func TestEngineMapsMultiSelectNumbersToOptionLabels(t *testing.T) { } } +func TestQuestionPromptHintsMatchType(t *testing.T) { + single := questionPrompt(PendingQuestion{Questions: []Question{{ + Question: "Pick one", + Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}}) + if !strings.HasSuffix(single, "Reply with a number or answer text.") { + t.Fatalf("single prompt = %q", single) + } + + multiSelect := questionPrompt(PendingQuestion{Questions: []Question{{ + Question: "Pick several", + Multiple: true, + Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}}) + if !strings.Contains(multiSelect, "separated by commas") { + t.Fatalf("multi-select prompt = %q", multiSelect) + } + + multiQuestion := questionPrompt(PendingQuestion{Questions: []Question{ + {Question: "First?"}, + {Question: "Second?"}, + }}) + if !strings.Contains(multiQuestion, "one line per question") { + t.Fatalf("multi-question prompt = %q", multiQuestion) + } +} + +func TestMultiSelectAcceptsFullWidthAndIdeographicCommas(t *testing.T) { + pending := PendingQuestion{Questions: []Question{{ + Multiple: true, + Options: []QuestionOption{{Label: "A"}, {Label: "B"}, {Label: "C"}}, + }}} + for _, input := range []string{"1,3", "1,3", "1、3", "1, 3"} { + answers, err := answersForQuestionText(pending, input) + if err != nil { + t.Fatalf("input %q: %v", input, err) + } + if len(answers) != 1 || len(answers[0]) != 2 || answers[0][0] != "A" || answers[0][1] != "C" { + t.Fatalf("input %q answers = %#v", input, answers) + } + } +} + func TestEngineAnswersPendingQuestionsInArrivalOrder(t *testing.T) { sidecar := &fakeSidecar{} platform := &fakePlatform{} From ed762e1c6485c06cf7e691e0e9b542defdfaa870 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 19:15:08 +0800 Subject: [PATCH 04/24] fix(remote-bridge): bound JSON requests with a per-request timeout Review P2. The client used http.DefaultClient (no timeout) and JSON calls rode only the outer ctx, so a stalled PawWork sidecar could hang startup and hydration indefinitely. - doJSONWithDirectory (the single JSON funnel) wraps ctx with a 30s timeout - the SSE stream builds its own request and stays intentionally unbounded - test: a slow server that never responds makes ListSessions return promptly --- packages/remote-bridge/internal/pawwork/client.go | 10 ++++++++++ .../remote-bridge/internal/pawwork/client_test.go | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index d2a770c22..9ba0e9c11 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -12,6 +12,7 @@ import ( "net/url" "strings" "sync" + "time" "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" ) @@ -44,6 +45,7 @@ type Client struct { username string password string defaultDirectory string + jsonTimeout time.Duration lastEventID string eventCursorStore bridge.EventCursorStore mu sync.Mutex @@ -69,6 +71,7 @@ func NewWithDirectoryAndAuth(baseURL string, directory string, username string, username: username, password: password, defaultDirectory: directory, + jsonTimeout: 30 * time.Second, sessionDirectories: make(map[string]string), } } @@ -262,6 +265,13 @@ func (c *Client) doSessionJSON(ctx context.Context, sessionID string, method str } func (c *Client) doJSONWithDirectory(ctx context.Context, directory string, method string, path string, input any, output any) error { + // Bound every JSON request so a stalled sidecar cannot hang startup/hydration. + // The SSE stream (StreamEvents) builds its own request and is intentionally exempt. + if c.jsonTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.jsonTimeout) + defer cancel() + } var body io.Reader if input != nil { data, err := json.Marshal(input) diff --git a/packages/remote-bridge/internal/pawwork/client_test.go b/packages/remote-bridge/internal/pawwork/client_test.go index f5dc67f48..099ffeb09 100644 --- a/packages/remote-bridge/internal/pawwork/client_test.go +++ b/packages/remote-bridge/internal/pawwork/client_test.go @@ -8,6 +8,7 @@ import ( "net/url" "path/filepath" "testing" + "time" "github.com/astro-han/pawwork/packages/remote-bridge/internal/bridge" ) @@ -82,6 +83,19 @@ func TestClientUsesPawWorkSessionEndpoints(t *testing.T) { } } +func TestClientJSONRequestTimesOut(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL) + client.jsonTimeout = 50 * time.Millisecond + if _, err := client.ListSessions(t.Context(), 5); err == nil { + t.Fatal("expected JSON request to time out") + } +} + func TestClientFetchesSessionDirectoryWhenMissing(t *testing.T) { var promptDirectory string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 5c5cf775ed489574dda25eb65b0fb0c0ad47b788 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 19:31:26 +0800 Subject: [PATCH 05/24] fix(remote-bridge): retry transient assistant-text delivery failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1. parseSSE advances the global SSE cursor even when a chat-platform delivery fails, so a transient blip silently drops the assistant message. The cursor is global and shared, so holding it (the reviewer's proposed replay) would wedge every session's stream on any permanent failure. First principle: the cursor tracks ingestion, not delivery. Keep it advancing; make delivery best-effort-good by retrying at the sink. - deliverWithRetry: assistant text gets bounded retries (3) with short backoff, then gives up (the caller logs it) — the cursor is never held - scoped to assistant text only: permissions/questions are already reconciled by the gateway's hydrate on every reconnect, and a test pins their delivery as single-shot, so they must not be retried here - test: transient failure recovers; sustained failure stops after N attempts The global-cursor wedge risk was confirmed by an independent review. --- .../remote-bridge/internal/bridge/engine.go | 34 +++++++++++++++- .../internal/bridge/engine_test.go | 40 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 84c59dfca..7e2a3f21a 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/chenhg5/cc-connect/core" ) @@ -220,7 +221,7 @@ func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text if !ok { return nil } - return sendDelivery(ctx, target, text) + return deliverWithRetry(ctx, target, text) } func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { @@ -337,6 +338,37 @@ func sendDelivery(ctx context.Context, target delivery, content string) error { return target.platform.Reply(ctx, target.replyCtx, content) } +// deliveryAttempts bounds how many times assistant text is pushed to a chat +// target before giving up. deliveryRetryBackoff is the base delay between +// attempts (scaled per attempt); it is a var only so tests can drop it to zero. +const deliveryAttempts = 3 + +var deliveryRetryBackoff = 200 * time.Millisecond + +// deliverWithRetry sends final assistant text, retrying transient platform +// errors with a short backoff. Assistant text is the only payload with no +// reconnect-time reconciliation (permissions and questions are re-surfaced by +// the gateway's hydrate), so a transient blip here would otherwise lose the +// message. The global SSE cursor tracks ingestion and advances regardless, so a +// target that stays unreachable is reported to the caller (logged) rather than +// held — holding the shared cursor would wedge every session's stream. +func deliverWithRetry(ctx context.Context, target delivery, content string) error { + var err error + for attempt := 1; attempt <= deliveryAttempts; attempt++ { + if err = sendDelivery(ctx, target, content); err == nil { + return nil + } + if attempt < deliveryAttempts { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt) * deliveryRetryBackoff): + } + } + } + return err +} + func (e *Engine) handlePendingReply( ctx context.Context, platform core.Platform, diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index d57c5c393..d8a668b0d 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -2,9 +2,11 @@ package bridge import ( "context" + "errors" "path/filepath" "strings" "testing" + "time" "github.com/chenhg5/cc-connect/core" ) @@ -70,6 +72,8 @@ type fakePlatform struct { replies []string sends []string reconstructKey string + replyFailures int + replyCalls int } func (f *fakePlatform) Name() string { @@ -80,6 +84,11 @@ func (f *fakePlatform) Name() string { } func (f *fakePlatform) Start(core.MessageHandler) error { return nil } func (f *fakePlatform) Reply(_ context.Context, _ any, content string) error { + f.replyCalls++ + if f.replyFailures > 0 { + f.replyFailures-- + return errors.New("transient delivery failure") + } f.replies = append(f.replies, content) return nil } @@ -647,6 +656,37 @@ func TestEngineMapsMultiSelectNumbersToOptionLabels(t *testing.T) { } } +func TestAssistantTextRetriesTransientDeliveryFailure(t *testing.T) { + defer func(b time.Duration) { deliveryRetryBackoff = b }(deliveryRetryBackoff) + deliveryRetryBackoff = 0 + + // Recovers when the platform fails transiently, then succeeds. + recovers := &fakePlatform{replyFailures: deliveryAttempts - 1} + engine := New(&fakeSidecar{}) + if err := engine.HandleMessage(context.Background(), recovers, &core.Message{SessionKey: "slack:dm:a", Content: "hi"}); err != nil { + t.Fatal(err) + } + if err := engine.HandleAssistantText(context.Background(), "ses_new", "answer"); err != nil { + t.Fatalf("assistant text should recover after retries: %v", err) + } + if recovers.replyCalls != deliveryAttempts || len(recovers.replies) != 1 || recovers.replies[0] != "answer" { + t.Fatalf("calls=%d replies=%#v", recovers.replyCalls, recovers.replies) + } + + // Gives up after a bounded number of attempts — never holds the cursor. + keepsFailing := &fakePlatform{replyFailures: deliveryAttempts + 5} + engine2 := New(&fakeSidecar{}) + if err := engine2.HandleMessage(context.Background(), keepsFailing, &core.Message{SessionKey: "slack:dm:b", Content: "hi"}); err != nil { + t.Fatal(err) + } + if err := engine2.HandleAssistantText(context.Background(), "ses_new", "answer"); err == nil { + t.Fatal("expected error after bounded retries") + } + if keepsFailing.replyCalls != deliveryAttempts { + t.Fatalf("attempts = %d, want %d", keepsFailing.replyCalls, deliveryAttempts) + } +} + func TestQuestionPromptHintsMatchType(t *testing.T) { single := questionPrompt(PendingQuestion{Questions: []Question{{ Question: "Pick one", From caee61ccddc2d1b6c272af145bed0bfb7d7c79bb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 19:45:53 +0800 Subject: [PATCH 06/24] test(ci): cover remote-bridge build.yml steps in meta-test --- packages/opencode/test/github/build-workflow.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/test/github/build-workflow.test.ts b/packages/opencode/test/github/build-workflow.test.ts index 8429c4644..d98f1979b 100644 --- a/packages/opencode/test/github/build-workflow.test.ts +++ b/packages/opencode/test/github/build-workflow.test.ts @@ -103,6 +103,8 @@ describe("release workflow", () => { const nonMacArtifactStep = steps.find((step) => step.name === "Upload packaged app artifact") const buildElectronAppStep = steps.find((step) => step.name === "Build Electron app") const runtimeImportGuardStep = steps.find((step) => step.name === "Check desktop runtime imports") + const remoteBridgeTestStep = steps.find((step) => step.name === "Test remote bridge") + const remoteBridgeRaceStep = steps.find((step) => step.name === "Race test remote bridge") const setupAppleApiKeyStep = steps.find((step) => step.name === "Setup Apple API Key") const deleteAppleApiKeyAfterSubmitStep = steps.find( (step) => step.name === "Delete Apple API Key after submit", @@ -195,6 +197,12 @@ describe("release workflow", () => { expect(runtimeImportGuardStep?.if).toBe("${{ inputs.phase != 'finalize' }}") expect(runtimeImportGuardStep?.run).toBe("bun ./scripts/runtime-import-guard.ts") expect(runtimeImportGuardStep?.["working-directory"]).toBe("packages/desktop-electron") + expect(remoteBridgeTestStep?.if).toBe("${{ inputs.phase != 'finalize' }}") + expect(remoteBridgeTestStep?.run).toBe("go test ./...") + expect(remoteBridgeTestStep?.["working-directory"]).toBe("packages/remote-bridge") + expect(remoteBridgeRaceStep?.if).toBe("${{ inputs.phase != 'finalize' }}") + expect(remoteBridgeRaceStep?.run).toBe("go test -race ./internal/bridge ./internal/gateway ./internal/pawwork") + expect(remoteBridgeRaceStep?.["working-directory"]).toBe("packages/remote-bridge") expect(steps.indexOf(runtimeImportGuardStep!)).toBeGreaterThan(steps.indexOf(buildElectronAppStep!)) expect(steps.indexOf(runtimeImportGuardStep!)).toBeLessThan(steps.indexOf(setupAppleApiKeyStep!)) expect(submitNotarizationStep).toBeDefined() From 7b50f983809738ce0a562c235071cae8d75486e8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:07:00 +0800 Subject: [PATCH 07/24] fix(remote-bridge): sort hydration directories for stable order --- packages/remote-bridge/internal/pawwork/client.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index 9ba0e9c11..c4c6343ef 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -10,6 +10,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -355,13 +356,18 @@ func (c *Client) knownDirectories() []string { seen[c.defaultDirectory] = true directories = append(directories, c.defaultDirectory) } + // Sort the map-derived directories so hydration visits them in a stable + // order; ranging a map directly is randomized and makes ordering flaky. + extra := []string{} for _, directory := range c.sessionDirectories { if directory == "" || seen[directory] { continue } seen[directory] = true - directories = append(directories, directory) + extra = append(extra, directory) } + slices.Sort(extra) + directories = append(directories, extra...) if len(directories) == 0 { return []string{""} } From 6fac1932feb26a5dc4b5d72348f49aa48d78a395 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:09:08 +0800 Subject: [PATCH 08/24] fix(remote-bridge): retry permission and question prompt delivery --- .../remote-bridge/internal/bridge/engine.go | 25 +++++++------ .../internal/bridge/engine_test.go | 37 +++++++++++++++++++ .../internal/gateway/gateway_test.go | 6 ++- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 7e2a3f21a..35bf17223 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -221,7 +221,7 @@ func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text if !ok { return nil } - return deliverWithRetry(ctx, target, text) + return sendDeliveryWithRetry(ctx, target, text) } func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { @@ -280,7 +280,7 @@ func (e *Engine) replyToActive(ctx context.Context, sessionID string, content st if !ok { return nil } - return sendDelivery(ctx, target, content) + return sendDeliveryWithRetry(ctx, target, content) } func (e *Engine) setActive(sessionID string, platform core.Platform, replyCtx any) { @@ -338,21 +338,22 @@ func sendDelivery(ctx context.Context, target delivery, content string) error { return target.platform.Reply(ctx, target.replyCtx, content) } -// deliveryAttempts bounds how many times assistant text is pushed to a chat -// target before giving up. deliveryRetryBackoff is the base delay between +// deliveryAttempts bounds how many times a user-visible payload is pushed to a +// chat target before giving up. deliveryRetryBackoff is the base delay between // attempts (scaled per attempt); it is a var only so tests can drop it to zero. const deliveryAttempts = 3 var deliveryRetryBackoff = 200 * time.Millisecond -// deliverWithRetry sends final assistant text, retrying transient platform -// errors with a short backoff. Assistant text is the only payload with no -// reconnect-time reconciliation (permissions and questions are re-surfaced by -// the gateway's hydrate), so a transient blip here would otherwise lose the -// message. The global SSE cursor tracks ingestion and advances regardless, so a -// target that stays unreachable is reported to the caller (logged) rather than -// held — holding the shared cursor would wedge every session's stream. -func deliverWithRetry(ctx context.Context, target delivery, content string) error { +// sendDeliveryWithRetry pushes a chat payload (assistant text, permission or +// question prompt), retrying transient platform errors with a short backoff. +// Permission and question prompts are also re-surfaced by the gateway's hydrate +// on reconnect, but reconnects are rare on a healthy stream, so without this a +// transient blip would hide a prompt until the next disconnect. The global SSE +// cursor tracks ingestion and advances regardless, so a target that stays +// unreachable is reported to the caller (logged) rather than held — holding the +// shared cursor would wedge every session's stream. +func sendDeliveryWithRetry(ctx context.Context, target delivery, content string) error { var err error for attempt := 1; attempt <= deliveryAttempts; attempt++ { if err = sendDelivery(ctx, target, content); err == nil { diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index d8a668b0d..95f81eda7 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -687,6 +687,43 @@ func TestAssistantTextRetriesTransientDeliveryFailure(t *testing.T) { } } +func TestPermissionAndQuestionPromptsRetryTransientDeliveryFailure(t *testing.T) { + defer func(b time.Duration) { deliveryRetryBackoff = b }(deliveryRetryBackoff) + deliveryRetryBackoff = 0 + + // A permission prompt recovers when delivery fails transiently then succeeds. + permPlatform := &fakePlatform{replyFailures: deliveryAttempts - 1} + permEngine := New(&fakeSidecar{}) + if err := permEngine.HandleMessage(context.Background(), permPlatform, &core.Message{SessionKey: "slack:dm:a", Content: "hi"}); err != nil { + t.Fatal(err) + } + if err := permEngine.HandlePermission(context.Background(), PendingPermission{ + ID: "perm_1", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/app.ts"}, + }); err != nil { + t.Fatalf("permission prompt should recover after retries: %v", err) + } + if permPlatform.replyCalls != deliveryAttempts || len(permPlatform.replies) != 1 { + t.Fatalf("calls=%d replies=%#v", permPlatform.replyCalls, permPlatform.replies) + } + + // A question prompt that keeps failing gives up after bounded attempts. + questionPlatform := &fakePlatform{replyFailures: deliveryAttempts + 5} + questionEngine := New(&fakeSidecar{}) + if err := questionEngine.HandleMessage(context.Background(), questionPlatform, &core.Message{SessionKey: "slack:dm:b", Content: "hi"}); err != nil { + t.Fatal(err) + } + if err := questionEngine.HandleQuestion(context.Background(), PendingQuestion{ + MessageID: "msg_1", SessionID: "ses_new", Questions: []Question{{ + Question: "Pick one", Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}, + }); err == nil { + t.Fatal("expected error after bounded retries") + } + if questionPlatform.replyCalls != deliveryAttempts { + t.Fatalf("attempts = %d, want %d", questionPlatform.replyCalls, deliveryAttempts) + } +} + func TestQuestionPromptHintsMatchType(t *testing.T) { single := questionPrompt(PendingQuestion{Questions: []Question{{ Question: "Pick one", diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go index baf07776e..44e6beb75 100644 --- a/packages/remote-bridge/internal/gateway/gateway_test.go +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -376,9 +376,11 @@ func TestHydrateContinuesWhenPendingDeliveryFails(t *testing.T) { } if err := app.hydrate(t.Context()); err != nil { - t.Fatalf("hydrate should keep running after a single pending delivery fails: %v", err) + t.Fatalf("hydrate should keep running after a pending delivery fails: %v", err) } - if len(platform.sends) != 1 { + // The prompt delivery is retried up to deliveryAttempts times before hydrate + // gives up and moves on — it must never hold the cursor or abort hydrate. + if len(platform.sends) != 3 { t.Fatalf("sends = %#v", platform.sends) } } From a583271f847044e8352f133537b90743d8a78dba Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:10:04 +0800 Subject: [PATCH 09/24] fix(remote-bridge): fail fast on non-SSE event stream response --- packages/remote-bridge/internal/pawwork/client.go | 15 +++++++++++++++ packages/remote-bridge/internal/pawwork/events.go | 3 +-- .../remote-bridge/internal/pawwork/events_test.go | 7 ++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index c4c6343ef..1691028c5 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -30,7 +30,22 @@ func (e *HTTPStatusError) Error() string { return fmt.Sprintf("%s %s failed: %s %s", e.Method, e.Path, e.Status, e.Body) } +// StreamProtocolError marks an event stream that connected (2xx) but did not +// speak text/event-stream. Retrying cannot fix a protocol mismatch, so it is +// fatal and must stop the reconnect loop rather than spin forever. +type StreamProtocolError struct { + ContentType string +} + +func (e *StreamProtocolError) Error() string { + return fmt.Sprintf("GET /global/event failed: expected text/event-stream, got %q", e.ContentType) +} + func IsFatalStreamError(err error) bool { + var proto *StreamProtocolError + if errors.As(err, &proto) { + return true + } var status *HTTPStatusError if !errors.As(err, &status) { return false diff --git a/packages/remote-bridge/internal/pawwork/events.go b/packages/remote-bridge/internal/pawwork/events.go index 7e71bf2c1..9c69ec7ef 100644 --- a/packages/remote-bridge/internal/pawwork/events.go +++ b/packages/remote-bridge/internal/pawwork/events.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "log/slog" "mime" @@ -116,7 +115,7 @@ func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { } mediaType, _, err := mime.ParseMediaType(res.Header.Get("content-type")) if err != nil || !strings.EqualFold(mediaType, "text/event-stream") { - return fmt.Errorf("GET /global/event failed: expected text/event-stream, got %q", res.Header.Get("content-type")) + return &StreamProtocolError{ContentType: res.Header.Get("content-type")} } if ready, ok := handler.(StreamReadyHandler); ok { if err := ready.HandleStreamReady(ctx); err != nil { diff --git a/packages/remote-bridge/internal/pawwork/events_test.go b/packages/remote-bridge/internal/pawwork/events_test.go index ba06ef7fb..bf53e0ae1 100644 --- a/packages/remote-bridge/internal/pawwork/events_test.go +++ b/packages/remote-bridge/internal/pawwork/events_test.go @@ -342,9 +342,14 @@ func TestClientRejectsNonSSEEventStreamBeforeReady(t *testing.T) { defer server.Close() handler := &streamReadyEventHandler{} - if err := New(server.URL).StreamEvents(t.Context(), handler); err == nil { + err := New(server.URL).StreamEvents(t.Context(), handler) + if err == nil { t.Fatal("expected non-SSE stream response to fail") } + // Must be fatal so the gateway fails fast instead of reconnecting forever. + if !IsFatalStreamError(err) { + t.Fatalf("non-SSE stream error should be fatal, got %v", err) + } if handler.ready != 0 { t.Fatalf("ready calls = %d", handler.ready) } From 23e6466e3ca70e4f8dc343a4542e795eaa155e68 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:10:57 +0800 Subject: [PATCH 10/24] fix(remote-bridge): surface non-transient hydration errors --- .../remote-bridge/internal/pawwork/client.go | 18 ++++++++++--- .../internal/pawwork/client_test.go | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index 1691028c5..179dd9988 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/url" "slices" @@ -258,14 +259,23 @@ func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, e return questions, nil } +// canSkipHydrationDirectoryError reports whether a per-directory hydration +// failure is transient enough to skip and continue. Only explicitly transient +// signals qualify — request timeouts, rate limits, 5xx, and network/deadline +// timeouts. Anything else (JSON decode, schema/protocol errors) surfaces, since +// silently dropping it would hide pending permissions or questions. func canSkipHydrationDirectoryError(err error) bool { var status *HTTPStatusError - if !errors.As(err, &status) { + if errors.As(err, &status) { + return status.StatusCode == http.StatusRequestTimeout || + status.StatusCode == http.StatusTooManyRequests || + status.StatusCode >= 500 + } + if errors.Is(err, context.DeadlineExceeded) { return true } - return status.StatusCode == http.StatusRequestTimeout || - status.StatusCode == http.StatusTooManyRequests || - status.StatusCode >= 500 + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() } func (c *Client) doJSON(ctx context.Context, method string, path string, input any, output any) error { diff --git a/packages/remote-bridge/internal/pawwork/client_test.go b/packages/remote-bridge/internal/pawwork/client_test.go index 099ffeb09..74a223f11 100644 --- a/packages/remote-bridge/internal/pawwork/client_test.go +++ b/packages/remote-bridge/internal/pawwork/client_test.go @@ -331,6 +331,31 @@ func TestClientListPermissionsReturnsFatalDirectoryError(t *testing.T) { } } +func TestClientListPermissionsSurfacesMalformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/experimental/session": + writeJSON(t, w, []map[string]any{{"id": "ses_a", "directory": "/repo/a"}}) + case r.Method == http.MethodGet && r.URL.Path == "/permission": + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{not json`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + // A malformed 2xx body is a protocol error, not a transient blip — it must + // surface rather than be skipped and silently drop pending permissions. + if _, err := client.ListPermissions(t.Context()); err == nil { + t.Fatal("expected malformed JSON to surface as an error") + } +} + func TestClientListQuestionsSkipsFailingDirectory(t *testing.T) { questionDirectories := []string{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 95169fcec13d361e284c3c940d1ad651c69ece57 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:26:12 +0800 Subject: [PATCH 11/24] fix(remote-bridge): only record pending after prompt is delivered --- .../remote-bridge/internal/bridge/engine.go | 28 ++++++++++++---- .../internal/bridge/engine_test.go | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 35bf17223..6d619f7f0 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -225,13 +225,19 @@ func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text } func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { - e.SetPendingPermission(permission) - return e.replyToActive(ctx, permission.SessionID, permissionPrompt(permission)) + delivered, err := e.replyToActive(ctx, permission.SessionID, permissionPrompt(permission)) + if delivered { + e.SetPendingPermission(permission) + } + return err } func (e *Engine) HandleQuestion(ctx context.Context, question PendingQuestion) error { - e.SetPendingQuestion(question) - return e.replyToActive(ctx, question.SessionID, questionPrompt(question)) + delivered, err := e.replyToActive(ctx, question.SessionID, questionPrompt(question)) + if delivered { + e.SetPendingQuestion(question) + } + return err } func (e *Engine) HandlePermissionResolved(_ context.Context, resolution PermissionResolution) error { @@ -275,12 +281,20 @@ func (e *Engine) HandleSession(_ context.Context, session Session) error { return e.RegisterSession(session) } -func (e *Engine) replyToActive(ctx context.Context, sessionID string, content string) error { +// replyToActive pushes content to the session's active chat target and reports +// whether it was delivered. Callers must only record a local pending blocker +// when delivered is true: a blocker set for a prompt the user never saw would +// hijack their next ordinary message as an answer. An undelivered prompt stays +// pending server-side and is re-surfaced on the next hydrate/reconnect. +func (e *Engine) replyToActive(ctx context.Context, sessionID string, content string) (bool, error) { target, ok := e.activeDelivery(sessionID) if !ok { - return nil + return false, nil + } + if err := sendDeliveryWithRetry(ctx, target, content); err != nil { + return false, err } - return sendDeliveryWithRetry(ctx, target, content) + return true, nil } func (e *Engine) setActive(sessionID string, platform core.Platform, replyCtx any) { diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index 95f81eda7..1d06caecb 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -724,6 +724,38 @@ func TestPermissionAndQuestionPromptsRetryTransientDeliveryFailure(t *testing.T) } } +func TestUndeliveredPromptDoesNotInterceptNextMessage(t *testing.T) { + defer func(b time.Duration) { deliveryRetryBackoff = b }(deliveryRetryBackoff) + deliveryRetryBackoff = 0 + + sidecar := &fakeSidecar{} + // Delivery always fails, so the permission prompt never reaches the user. + platform := &fakePlatform{replyFailures: deliveryAttempts} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:a", Content: "edit the file"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if err := engine.HandlePermission(context.Background(), PendingPermission{ + ID: "perm_1", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/app.ts"}, + }); err == nil { + t.Fatal("expected delivery failure to be reported") + } + + // The next ordinary message must be forwarded as a prompt, not intercepted + // as a permission answer, because the user never saw the prompt. + msg.Content = "what is the weather" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.permissionReplies) != 0 { + t.Fatalf("message was intercepted as a permission answer: %#v", sidecar.permissionReplies) + } + if len(sidecar.prompts) != 2 || sidecar.prompts[1].text != "what is the weather" { + t.Fatalf("message was not forwarded as a prompt: %#v", sidecar.prompts) + } +} + func TestQuestionPromptHintsMatchType(t *testing.T) { single := questionPrompt(PendingQuestion{Questions: []Question{{ Question: "Pick one", From e1fa7b4b23f1c6590dbb7d1f87c76d6222869b94 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:26:36 +0800 Subject: [PATCH 12/24] refactor(remote-bridge): collapse answer separator switch to one line --- packages/remote-bridge/internal/bridge/engine.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 6d619f7f0..c33d58fbd 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -775,11 +775,7 @@ func answerRowForQuestion(text string, question Question) []string { // Accepts the ASCII comma plus the full-width and ideographic commas so replies // typed on a Chinese keyboard ("1,3" / "1、3") parse the same as ASCII ones. func isAnswerSeparator(r rune) bool { - switch r { - case ',', ',', '、': - return true - } - return false + return r == ',' || r == ',' || r == '、' } func answerTokenForQuestion(text string, question Question) string { From d45c2ace4dd31d727a3b45a41d54992892e2a2b4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 20:37:36 +0800 Subject: [PATCH 13/24] refactor(remote-bridge): unexport pending setters --- packages/remote-bridge/internal/bridge/engine.go | 8 ++++---- packages/remote-bridge/internal/bridge/engine_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index c33d58fbd..e300d9169 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -162,7 +162,7 @@ func (e *Engine) RegisterSession(session Session) error { return e.pointers.SetParent(session.ID, session.ParentID) } -func (e *Engine) SetPendingPermission(permission PendingPermission) { +func (e *Engine) setPendingPermission(permission PendingPermission) { if permission.SessionID == "" { return } @@ -175,7 +175,7 @@ func (e *Engine) SetPendingPermission(permission PendingPermission) { e.permissions[key] = permission } -func (e *Engine) SetPendingQuestion(question PendingQuestion) { +func (e *Engine) setPendingQuestion(question PendingQuestion) { if question.SessionID == "" { return } @@ -227,7 +227,7 @@ func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { delivered, err := e.replyToActive(ctx, permission.SessionID, permissionPrompt(permission)) if delivered { - e.SetPendingPermission(permission) + e.setPendingPermission(permission) } return err } @@ -235,7 +235,7 @@ func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermiss func (e *Engine) HandleQuestion(ctx context.Context, question PendingQuestion) error { delivered, err := e.replyToActive(ctx, question.SessionID, questionPrompt(question)) if delivered { - e.SetPendingQuestion(question) + e.setPendingQuestion(question) } return err } diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index 1d06caecb..c56f8fb15 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -271,7 +271,7 @@ func TestEngineRoutesPendingPermissionRepliesBeforePrompts(t *testing.T) { t.Fatal(err) } - engine.SetPendingPermission(PendingPermission{ + engine.setPendingPermission(PendingPermission{ ID: "perm_1", SessionID: "ses_new", Permission: "edit", @@ -318,7 +318,7 @@ func TestEngineRoutesPendingQuestionAnswersBeforePrompts(t *testing.T) { Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, }}, } - engine.SetPendingQuestion(pending) + engine.setPendingQuestion(pending) msg.Content = "2" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { @@ -897,7 +897,7 @@ func TestEngineClearsPermissionResolvedOutsideRemote(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } - engine.SetPendingPermission(PendingPermission{ + engine.setPendingPermission(PendingPermission{ ID: "perm_1", SessionID: "ses_new", Permission: "edit", @@ -928,7 +928,7 @@ func TestEngineClearsQuestionResolvedOutsideRemote(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } - engine.SetPendingQuestion(PendingQuestion{ + engine.setPendingQuestion(PendingQuestion{ SessionID: "ses_new", MessageID: "msg_1", CallID: "call_1", From 7150a50f37e94748ca6ecfcdc5865f0fa98ea0d9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 21:03:11 +0800 Subject: [PATCH 14/24] fix(remote-bridge): surface caller context cancellation during hydration --- .../remote-bridge/internal/pawwork/client.go | 14 +++++-- .../internal/pawwork/client_test.go | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/remote-bridge/internal/pawwork/client.go b/packages/remote-bridge/internal/pawwork/client.go index 179dd9988..0d866d512 100644 --- a/packages/remote-bridge/internal/pawwork/client.go +++ b/packages/remote-bridge/internal/pawwork/client.go @@ -216,7 +216,7 @@ func (c *Client) ListPermissions(ctx context.Context) ([]bridge.PendingPermissio Patterns []string `json:"patterns"` } if err := c.doJSONWithDirectory(ctx, directory, http.MethodGet, "/permission", nil, &raw); err != nil { - if !canSkipHydrationDirectoryError(err) { + if !canSkipHydrationDirectoryError(ctx, err) { return nil, err } slog.Warn("remote bridge could not list permissions", "directory", directory, "error", err) @@ -240,7 +240,7 @@ func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, e for _, directory := range c.knownDirectories() { var raw []json.RawMessage if err := c.doJSONWithDirectory(ctx, directory, http.MethodGet, "/external-result", nil, &raw); err != nil { - if !canSkipHydrationDirectoryError(err) { + if !canSkipHydrationDirectoryError(ctx, err) { return nil, err } slog.Warn("remote bridge could not list questions", "directory", directory, "error", err) @@ -263,8 +263,14 @@ func (c *Client) ListQuestions(ctx context.Context) ([]bridge.PendingQuestion, e // failure is transient enough to skip and continue. Only explicitly transient // signals qualify — request timeouts, rate limits, 5xx, and network/deadline // timeouts. Anything else (JSON decode, schema/protocol errors) surfaces, since -// silently dropping it would hide pending permissions or questions. -func canSkipHydrationDirectoryError(err error) bool { +// silently dropping it would hide pending permissions or questions. If the +// caller's own context is already done, that is a whole-operation cancel/ +// deadline, not a per-directory blip — surface it instead of returning a +// partial-success hydration. +func canSkipHydrationDirectoryError(ctx context.Context, err error) bool { + if ctx.Err() != nil { + return false + } var status *HTTPStatusError if errors.As(err, &status) { return status.StatusCode == http.StatusRequestTimeout || diff --git a/packages/remote-bridge/internal/pawwork/client_test.go b/packages/remote-bridge/internal/pawwork/client_test.go index 74a223f11..79cbde318 100644 --- a/packages/remote-bridge/internal/pawwork/client_test.go +++ b/packages/remote-bridge/internal/pawwork/client_test.go @@ -1,6 +1,7 @@ package pawwork import ( + "context" "encoding/json" "errors" "net/http" @@ -356,6 +357,42 @@ func TestClientListPermissionsSurfacesMalformedJSON(t *testing.T) { } } +func TestCanSkipHydrationDirectoryErrorRespectsCallerContext(t *testing.T) { + skippable := &HTTPStatusError{StatusCode: http.StatusBadGateway} + // A normally-skippable per-directory 5xx stays skippable under a live context. + if !canSkipHydrationDirectoryError(t.Context(), skippable) { + t.Fatal("directory 5xx should remain skippable under a live context") + } + // But an already-expired caller context is a whole-operation deadline, not a + // per-directory blip — it must surface, even for the same 5xx. + expired, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Minute)) + defer cancel() + if canSkipHydrationDirectoryError(expired, skippable) { + t.Fatal("expired caller context must not be skipped") + } +} + +func TestClientListPermissionsSurfacesCallerContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/experimental/session" { + writeJSON(t, w, []map[string]any{{"id": "ses_a", "directory": "/repo/a"}}) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + })) + defer server.Close() + + client := New(server.URL) + if _, err := client.ListSessions(t.Context(), 5); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := client.ListPermissions(ctx); err == nil { + t.Fatal("expected canceled caller context to surface, not partial success") + } +} + func TestClientListQuestionsSkipsFailingDirectory(t *testing.T) { questionDirectories := []string{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 89c2ad93e4a229224c99cd076991d485ef425c6b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 21:30:10 +0800 Subject: [PATCH 15/24] fix(remote-bridge): show one pending prompt at a time per session --- .../remote-bridge/internal/bridge/engine.go | 162 +++++++++++++----- .../internal/bridge/engine_test.go | 70 ++++++-- .../internal/gateway/gateway_test.go | 8 +- 3 files changed, 177 insertions(+), 63 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index e300d9169..b33737dae 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -107,8 +107,9 @@ const ( ) type blockerRef struct { - kind blockerKind - key string + kind blockerKind + key string + delivered bool } type pendingBlocker struct { @@ -225,56 +226,60 @@ func (e *Engine) HandleAssistantText(ctx context.Context, sessionID string, text } func (e *Engine) HandlePermission(ctx context.Context, permission PendingPermission) error { - delivered, err := e.replyToActive(ctx, permission.SessionID, permissionPrompt(permission)) - if delivered { - e.setPendingPermission(permission) - } - return err + e.setPendingPermission(permission) + return e.surfaceActiveBlocker(ctx, permission.SessionID) } func (e *Engine) HandleQuestion(ctx context.Context, question PendingQuestion) error { - delivered, err := e.replyToActive(ctx, question.SessionID, questionPrompt(question)) - if delivered { - e.setPendingQuestion(question) - } - return err + e.setPendingQuestion(question) + return e.surfaceActiveBlocker(ctx, question.SessionID) } -func (e *Engine) HandlePermissionResolved(_ context.Context, resolution PermissionResolution) error { +func (e *Engine) HandlePermissionResolved(ctx context.Context, resolution PermissionResolution) error { e.mu.Lock() - defer e.mu.Unlock() + affected := "" if resolution.RequestID != "" { + if permission, ok := e.permissions[resolution.RequestID]; ok { + affected = permission.SessionID + } e.clearPermissionKeyLocked(resolution.RequestID) - return nil - } - if resolution.SessionID != "" { + } else if resolution.SessionID != "" { + affected = resolution.SessionID e.clearPermissionsLocked(func(permission PendingPermission) bool { return permission.SessionID == resolution.SessionID }) } - return nil + e.mu.Unlock() + if affected == "" { + return nil + } + return e.surfaceActiveBlocker(ctx, affected) } -func (e *Engine) HandleQuestionResolved(_ context.Context, resolution QuestionResolution) error { +func (e *Engine) HandleQuestionResolved(ctx context.Context, resolution QuestionResolution) error { e.mu.Lock() - defer e.mu.Unlock() + affected := "" if resolution.CallID != "" || resolution.MessageID != "" { for key, question := range e.questions { callMatches := resolution.CallID == "" || question.CallID == resolution.CallID messageMatches := resolution.MessageID == "" || question.MessageID == resolution.MessageID if callMatches && messageMatches { + affected = question.SessionID e.clearQuestionKeyLocked(key) - return nil + break } } - return nil - } - if resolution.SessionID != "" { + } else if resolution.SessionID != "" { + affected = resolution.SessionID e.clearQuestionsLocked(func(question PendingQuestion) bool { return question.SessionID == resolution.SessionID }) } - return nil + e.mu.Unlock() + if affected == "" { + return nil + } + return e.surfaceActiveBlocker(ctx, affected) } func (e *Engine) HandleSession(_ context.Context, session Session) error { @@ -282,10 +287,10 @@ func (e *Engine) HandleSession(_ context.Context, session Session) error { } // replyToActive pushes content to the session's active chat target and reports -// whether it was delivered. Callers must only record a local pending blocker -// when delivered is true: a blocker set for a prompt the user never saw would -// hijack their next ordinary message as an answer. An undelivered prompt stays -// pending server-side and is re-surfaced on the next hydrate/reconnect. +// whether it was delivered. A blocker is only marked delivered (and so made +// answerable) once this returns true; a prompt the user never saw must not +// hijack their next ordinary message as an answer. With no active target it +// returns (false, nil) and the blocker stays queued for the next hydrate. func (e *Engine) replyToActive(ctx context.Context, sessionID string, content string) (bool, error) { target, ok := e.activeDelivery(sessionID) if !ok { @@ -405,6 +410,7 @@ func (e *Engine) handlePendingReply( return true, err } e.clearPendingPermission(blocker.permission) + _ = e.surfaceActiveBlocker(ctx, sessionID) return true, nil } if blocker.kind == questionBlocker { @@ -417,32 +423,28 @@ func (e *Engine) handlePendingReply( return true, err } e.clearPendingQuestion(blocker.question) + _ = e.surfaceActiveBlocker(ctx, sessionID) return true, nil } return false, nil } +// pendingBlocker returns the single active blocker for a root session: the +// earliest live one, and only once it has actually been delivered to chat. A +// queued-but-not-yet-shown blocker is never returned, so a user reply only ever +// answers the one prompt currently in front of them. func (e *Engine) pendingBlocker(sessionID string) (pendingBlocker, bool) { e.mu.Lock() defer e.mu.Unlock() root := e.pointers.RootSession(sessionID) - var kind blockerKind - found := false - for index := len(e.blockerOrder) - 1; index >= 0; index-- { - if ref := e.blockerOrder[index]; e.blockerLiveForRootLocked(ref, root) { - kind = ref.kind - found = true - break - } - } - if !found { - return pendingBlocker{}, false - } for _, ref := range e.blockerOrder { - if ref.kind != kind || !e.blockerLiveForRootLocked(ref, root) { + if !e.blockerLiveForRootLocked(ref, root) { continue } - switch kind { + if !ref.delivered { + return pendingBlocker{}, false + } + switch ref.kind { case permissionBlocker: return pendingBlocker{kind: permissionBlocker, permission: e.permissions[ref.key]}, true case questionBlocker: @@ -452,6 +454,80 @@ func (e *Engine) pendingBlocker(sessionID string) (pendingBlocker, bool) { return pendingBlocker{}, false } +// surfaceActiveBlocker delivers the root's current head prompt if it has not +// been shown yet, so chat only ever displays one pending item at a time. If +// delivery keeps failing the blocker is dropped (so it cannot silently +// intercept the next message) and the following one is tried; a missing chat +// target leaves it queued for the next hydrate. Returns the delivery error of a +// head that could not be shown, for the caller to log. +func (e *Engine) surfaceActiveBlocker(ctx context.Context, sessionID string) error { + var lastErr error + for { + ref, blockerSessionID, content, ok := e.headPromptToDeliver(sessionID) + if !ok { + return lastErr + } + delivered, err := e.replyToActive(ctx, blockerSessionID, content) + if delivered { + e.markBlockerDelivered(ref) + return nil + } + if err == nil { + return lastErr + } + lastErr = err + e.dropBlocker(ref) + } +} + +// headPromptToDeliver returns the root's head blocker and its rendered prompt +// when that head still needs delivering. ok is false when there is no live +// blocker or the head has already been shown. +func (e *Engine) headPromptToDeliver(sessionID string) (ref blockerRef, blockerSessionID string, content string, ok bool) { + e.mu.Lock() + defer e.mu.Unlock() + root := e.pointers.RootSession(sessionID) + for _, candidate := range e.blockerOrder { + if !e.blockerLiveForRootLocked(candidate, root) { + continue + } + if candidate.delivered { + return blockerRef{}, "", "", false + } + switch candidate.kind { + case permissionBlocker: + permission := e.permissions[candidate.key] + return candidate, permission.SessionID, permissionPrompt(permission), true + case questionBlocker: + question := e.questions[candidate.key] + return candidate, question.SessionID, questionPrompt(question), true + } + } + return blockerRef{}, "", "", false +} + +func (e *Engine) markBlockerDelivered(ref blockerRef) { + e.mu.Lock() + defer e.mu.Unlock() + for index := range e.blockerOrder { + if e.blockerOrder[index].kind == ref.kind && e.blockerOrder[index].key == ref.key { + e.blockerOrder[index].delivered = true + return + } + } +} + +func (e *Engine) dropBlocker(ref blockerRef) { + e.mu.Lock() + defer e.mu.Unlock() + switch ref.kind { + case permissionBlocker: + e.clearPermissionKeyLocked(ref.key) + case questionBlocker: + e.clearQuestionKeyLocked(ref.key) + } +} + func (e *Engine) blockerLiveForRootLocked(ref blockerRef, root string) bool { switch ref.kind { case permissionBlocker: diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index c56f8fb15..fb2e55f16 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -271,12 +271,14 @@ func TestEngineRoutesPendingPermissionRepliesBeforePrompts(t *testing.T) { t.Fatal(err) } - engine.setPendingPermission(PendingPermission{ + if err := engine.HandlePermission(context.Background(), PendingPermission{ ID: "perm_1", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/app.ts"}, - }) + }); err != nil { + t.Fatal(err) + } msg.Content = "yes" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { @@ -318,7 +320,9 @@ func TestEngineRoutesPendingQuestionAnswersBeforePrompts(t *testing.T) { Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, }}, } - engine.setPendingQuestion(pending) + if err := engine.HandleQuestion(context.Background(), pending); err != nil { + t.Fatal(err) + } msg.Content = "2" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { @@ -541,10 +545,19 @@ func TestEngineAnswersPendingPermissionsInArrivalOrder(t *testing.T) { } } + // Only the first permission is shown; the second stays queued until answered. + if len(platform.replies) != 1 || !strings.Contains(platform.replies[0], "/repo/a.ts") { + t.Fatalf("only the first permission should be shown: %#v", platform.replies) + } + msg.Content = "yes" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } + // Answering the first surfaces the second. + if len(platform.replies) != 2 || !strings.Contains(platform.replies[1], "/repo/b.ts") { + t.Fatalf("second permission should surface after the first: %#v", platform.replies) + } msg.Content = "no" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) @@ -826,10 +839,19 @@ func TestEngineAnswersPendingQuestionsInArrivalOrder(t *testing.T) { } } + // Only the first question is shown; the second stays queued until answered. + if len(platform.replies) != 1 || !strings.Contains(platform.replies[0], "First?") { + t.Fatalf("only the first question should be shown: %#v", platform.replies) + } + msg.Content = "1" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } + // Answering the first surfaces the second. + if len(platform.replies) != 2 || !strings.Contains(platform.replies[1], "Second?") { + t.Fatalf("second question should surface after the first: %#v", platform.replies) + } msg.Content = "2" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) @@ -848,7 +870,7 @@ func TestEngineAnswersPendingQuestionsInArrivalOrder(t *testing.T) { } } -func TestEngineRepliesToLatestVisibleBlocker(t *testing.T) { +func TestEngineSurfacesInterleavedBlockersOneAtATime(t *testing.T) { sidecar := &fakeSidecar{} platform := &fakePlatform{} engine := New(sidecar) @@ -856,6 +878,7 @@ func TestEngineRepliesToLatestVisibleBlocker(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } + // A permission arrives, then a question — only the permission is shown. if err := engine.HandlePermission(context.Background(), PendingPermission{ ID: "perm_1", SessionID: "ses_new", @@ -872,20 +895,31 @@ func TestEngineRepliesToLatestVisibleBlocker(t *testing.T) { }); err != nil { t.Fatal(err) } + if len(platform.replies) != 1 || !strings.Contains(platform.replies[0], "asks permission") { + t.Fatalf("only the permission should be shown first: %#v", platform.replies) + } - msg.Content = "2" + // A reply answers the visible permission, never the still-queued question. + msg.Content = "yes" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } - - if len(sidecar.questionReplies) != 1 || sidecar.questionReplies[0].pending.CallID != "call_1" { - t.Fatalf("question replies = %#v", sidecar.questionReplies) - } - if len(sidecar.permissionReplies) != 0 { + if len(sidecar.permissionReplies) != 1 || sidecar.permissionReplies[0].pending.ID != "perm_1" { t.Fatalf("permission replies = %#v", sidecar.permissionReplies) } - if len(sidecar.prompts) != 1 { - t.Fatalf("question answer became prompt: %#v", sidecar.prompts) + if len(sidecar.questionReplies) != 0 { + t.Fatalf("question answered while still queued: %#v", sidecar.questionReplies) + } + // Answering the permission surfaces the question, which the next reply answers. + if len(platform.replies) != 2 || !strings.Contains(platform.replies[1], "Pick one") { + t.Fatalf("question should surface after the permission: %#v", platform.replies) + } + msg.Content = "2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.questionReplies) != 1 || sidecar.questionReplies[0].answers[0][0] != "B" { + t.Fatalf("question replies = %#v", sidecar.questionReplies) } } @@ -897,12 +931,14 @@ func TestEngineClearsPermissionResolvedOutsideRemote(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } - engine.setPendingPermission(PendingPermission{ + if err := engine.HandlePermission(context.Background(), PendingPermission{ ID: "perm_1", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/app.ts"}, - }) + }); err != nil { + t.Fatal(err) + } if err := engine.HandlePermissionResolved(context.Background(), PermissionResolution{ SessionID: "ses_new", @@ -928,7 +964,7 @@ func TestEngineClearsQuestionResolvedOutsideRemote(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } - engine.setPendingQuestion(PendingQuestion{ + if err := engine.HandleQuestion(context.Background(), PendingQuestion{ SessionID: "ses_new", MessageID: "msg_1", CallID: "call_1", @@ -936,7 +972,9 @@ func TestEngineClearsQuestionResolvedOutsideRemote(t *testing.T) { Question: "Pick one", Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, }}, - }) + }); err != nil { + t.Fatal(err) + } if err := engine.HandleQuestionResolved(context.Background(), QuestionResolution{ SessionID: "ses_new", diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go index 44e6beb75..9938285fd 100644 --- a/packages/remote-bridge/internal/gateway/gateway_test.go +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -176,15 +176,15 @@ func TestHydrateResurfacesPendingInteractions(t *testing.T) { if platform.reconstructKey != "runtime-test-hydrate:room:alice" { t.Fatalf("reconstruct key = %q", platform.reconstructKey) } - if len(platform.sends) != 2 { + // Both pending items share root ses_root, so single-active surfacing shows + // only the first (the permission); the question stays queued until it is + // answered. Serialized one-at-a-time resurfacing is covered at engine level. + if len(platform.sends) != 1 { t.Fatalf("sends = %#v", platform.sends) } if !strings.Contains(platform.sends[0], "PawWork asks permission: edit") { t.Fatalf("permission send = %q", platform.sends[0]) } - if !strings.Contains(platform.sends[1], "Pick one") { - t.Fatalf("question send = %q", platform.sends[1]) - } } func TestRunStartsEventStreamBeforePlatforms(t *testing.T) { From 7fc2b334b2b02dc1302d5f995055a1bc47afde2a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 21:42:44 +0800 Subject: [PATCH 16/24] fix(remote-bridge): report failed surfacing of the next pending prompt After answering the current blocker, handlePendingReply auto-surfaces the next queued prompt. A persistently failing delivery dropped that prompt and discarded the error, leaving no log signal until the next hydrate. Propagate surfaceActiveBlocker's error up to HandleMessage so the gateway's existing warn logging catches it. --- .../remote-bridge/internal/bridge/engine.go | 6 +- .../internal/bridge/engine_test.go | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index b33737dae..086b64c0f 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -410,8 +410,7 @@ func (e *Engine) handlePendingReply( return true, err } e.clearPendingPermission(blocker.permission) - _ = e.surfaceActiveBlocker(ctx, sessionID) - return true, nil + return true, e.surfaceActiveBlocker(ctx, sessionID) } if blocker.kind == questionBlocker { answers, err := answersForQuestionText(blocker.question, text) @@ -423,8 +422,7 @@ func (e *Engine) handlePendingReply( return true, err } e.clearPendingQuestion(blocker.question) - _ = e.surfaceActiveBlocker(ctx, sessionID) - return true, nil + return true, e.surfaceActiveBlocker(ctx, sessionID) } return false, nil } diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index fb2e55f16..168d3d0dd 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -769,6 +769,102 @@ func TestUndeliveredPromptDoesNotInterceptNextMessage(t *testing.T) { } } +func TestSurfacingNextBlockerReportsPersistentDeliveryFailure(t *testing.T) { + defer func(b time.Duration) { deliveryRetryBackoff = b }(deliveryRetryBackoff) + deliveryRetryBackoff = 0 + + t.Run("permission", func(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:a", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + for _, permission := range []PendingPermission{ + {ID: "perm_first", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/a.ts"}}, + {ID: "perm_second", SessionID: "ses_new", Permission: "edit", Patterns: []string{"/repo/b.ts"}}, + } { + if err := engine.HandlePermission(context.Background(), permission); err != nil { + t.Fatal(err) + } + } + if len(platform.replies) != 1 { + t.Fatalf("only the first permission should be shown: %#v", platform.replies) + } + + // Answering the first surfaces the second, whose delivery now fails for good. + platform.replyFailures = deliveryAttempts + msg.Content = "yes" + if err := engine.HandleMessage(context.Background(), platform, msg); err == nil { + t.Fatal("a persistently failing next prompt must be reported to the caller") + } + // The first answer still went through; the second was never answered. + if len(sidecar.permissionReplies) != 1 || sidecar.permissionReplies[0].pending.ID != "perm_first" { + t.Fatalf("first answer should be recorded once: %#v", sidecar.permissionReplies) + } + + // The dropped second prompt must not intercept the next ordinary message. + platform.replyFailures = 0 + msg.Content = "what is the weather" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.permissionReplies) != 1 { + t.Fatalf("ordinary message was intercepted as a permission answer: %#v", sidecar.permissionReplies) + } + if last := sidecar.prompts[len(sidecar.prompts)-1]; last.text != "what is the weather" { + t.Fatalf("ordinary message was not forwarded as a prompt: %#v", sidecar.prompts) + } + }) + + t.Run("question", func(t *testing.T) { + sidecar := &fakeSidecar{} + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:b", Content: "start"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + for _, question := range []PendingQuestion{ + {MessageID: "msg_first", CallID: "call_first", SessionID: "ses_new", Questions: []Question{{ + Question: "Pick one", Options: []QuestionOption{{Label: "A"}, {Label: "B"}}, + }}}, + {MessageID: "msg_second", CallID: "call_second", SessionID: "ses_new", Questions: []Question{{ + Question: "Pick two", Options: []QuestionOption{{Label: "C"}, {Label: "D"}}, + }}}, + } { + if err := engine.HandleQuestion(context.Background(), question); err != nil { + t.Fatal(err) + } + } + if len(platform.replies) != 1 { + t.Fatalf("only the first question should be shown: %#v", platform.replies) + } + + platform.replyFailures = deliveryAttempts + msg.Content = "1" + if err := engine.HandleMessage(context.Background(), platform, msg); err == nil { + t.Fatal("a persistently failing next prompt must be reported to the caller") + } + if len(sidecar.questionReplies) != 1 || sidecar.questionReplies[0].pending.MessageID != "msg_first" { + t.Fatalf("first answer should be recorded once: %#v", sidecar.questionReplies) + } + + platform.replyFailures = 0 + msg.Content = "hello there" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.questionReplies) != 1 { + t.Fatalf("ordinary message was intercepted as a question answer: %#v", sidecar.questionReplies) + } + if last := sidecar.prompts[len(sidecar.prompts)-1]; last.text != "hello there" { + t.Fatalf("ordinary message was not forwarded as a prompt: %#v", sidecar.prompts) + } + }) +} + func TestQuestionPromptHintsMatchType(t *testing.T) { single := questionPrompt(PendingQuestion{Questions: []Question{{ Question: "Pick one", From 375ce70d6dbcb5d86db980c70d0eef9d8b730adf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 22:56:06 +0800 Subject: [PATCH 17/24] fix(remote-bridge): keep and re-surface an undelivered blocker When the next queued prompt failed to deliver, the blocker was dropped and only recovered at the next reconnect hydrate. Keep it undelivered instead: it stays unanswerable so it cannot intercept an ordinary message, and the next inbound message retries surfacing it now that the user is reachable. This deletes the drop/loop in surfaceActiveBlocker (the delivered flag already prevents interception). --- .../remote-bridge/internal/bridge/engine.go | 52 +++++++------------ .../internal/bridge/engine_test.go | 41 ++++++++++++--- 2 files changed, 52 insertions(+), 41 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 086b64c0f..46b938caf 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -206,11 +206,15 @@ func (e *Engine) HandleMessage(ctx context.Context, platform core.Platform, msg if handled, err := e.handlePendingReply(ctx, platform, msg, sessionID, text); handled || err != nil { return err } + e.setActive(sessionID, platform, msg.ReplyCtx) + // Retry an undelivered head prompt now that the user is reachable on this + // target. Best-effort: the message below still goes through as an ordinary + // prompt, since an unshown blocker must never intercept it. + _ = e.surfaceActiveBlocker(ctx, sessionID) if err := e.sidecar.SendPrompt(ctx, sessionID, text); err != nil { _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not send the message: "+err.Error()) return err } - e.setActive(sessionID, platform, msg.ReplyCtx) return nil } @@ -453,29 +457,22 @@ func (e *Engine) pendingBlocker(sessionID string) (pendingBlocker, bool) { } // surfaceActiveBlocker delivers the root's current head prompt if it has not -// been shown yet, so chat only ever displays one pending item at a time. If -// delivery keeps failing the blocker is dropped (so it cannot silently -// intercept the next message) and the following one is tried; a missing chat -// target leaves it queued for the next hydrate. Returns the delivery error of a -// head that could not be shown, for the caller to log. +// been shown yet, so chat only ever displays one pending item at a time. A head +// that fails to deliver is kept undelivered, never dropped: it stays +// unanswerable so it cannot intercept an ordinary message, and the next inbound +// message, a resolved sibling, or a reconnect hydrate retries surfacing it. +// Returns the delivery error so the caller can log it; a missing chat target +// returns nil and leaves the head queued. func (e *Engine) surfaceActiveBlocker(ctx context.Context, sessionID string) error { - var lastErr error - for { - ref, blockerSessionID, content, ok := e.headPromptToDeliver(sessionID) - if !ok { - return lastErr - } - delivered, err := e.replyToActive(ctx, blockerSessionID, content) - if delivered { - e.markBlockerDelivered(ref) - return nil - } - if err == nil { - return lastErr - } - lastErr = err - e.dropBlocker(ref) + ref, blockerSessionID, content, ok := e.headPromptToDeliver(sessionID) + if !ok { + return nil + } + if delivered, err := e.replyToActive(ctx, blockerSessionID, content); !delivered { + return err } + e.markBlockerDelivered(ref) + return nil } // headPromptToDeliver returns the root's head blocker and its rendered prompt @@ -515,17 +512,6 @@ func (e *Engine) markBlockerDelivered(ref blockerRef) { } } -func (e *Engine) dropBlocker(ref blockerRef) { - e.mu.Lock() - defer e.mu.Unlock() - switch ref.kind { - case permissionBlocker: - e.clearPermissionKeyLocked(ref.key) - case questionBlocker: - e.clearQuestionKeyLocked(ref.key) - } -} - func (e *Engine) blockerLiveForRootLocked(ref blockerRef, root string) bool { switch ref.kind { case permissionBlocker: diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index 168d3d0dd..4593fb191 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -742,8 +742,9 @@ func TestUndeliveredPromptDoesNotInterceptNextMessage(t *testing.T) { deliveryRetryBackoff = 0 sidecar := &fakeSidecar{} - // Delivery always fails, so the permission prompt never reaches the user. - platform := &fakePlatform{replyFailures: deliveryAttempts} + // Delivery keeps failing across the initial surface and the re-surface + // retry, so the permission prompt never reaches the user. + platform := &fakePlatform{replyFailures: 100} engine := New(sidecar) msg := &core.Message{SessionKey: "slack:dm:a", Content: "edit the file"} if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { @@ -769,7 +770,7 @@ func TestUndeliveredPromptDoesNotInterceptNextMessage(t *testing.T) { } } -func TestSurfacingNextBlockerReportsPersistentDeliveryFailure(t *testing.T) { +func TestFailedNextBlockerIsKeptAndResurfacedOnRecovery(t *testing.T) { defer func(b time.Duration) { deliveryRetryBackoff = b }(deliveryRetryBackoff) deliveryRetryBackoff = 0 @@ -793,29 +794,42 @@ func TestSurfacingNextBlockerReportsPersistentDeliveryFailure(t *testing.T) { t.Fatalf("only the first permission should be shown: %#v", platform.replies) } - // Answering the first surfaces the second, whose delivery now fails for good. + // Answering the first surfaces the second, whose delivery now fails. platform.replyFailures = deliveryAttempts msg.Content = "yes" if err := engine.HandleMessage(context.Background(), platform, msg); err == nil { - t.Fatal("a persistently failing next prompt must be reported to the caller") + t.Fatal("a failed surfacing of the next prompt must be reported to the caller") } - // The first answer still went through; the second was never answered. + // The first answer went through; the second is neither answered nor dropped. if len(sidecar.permissionReplies) != 1 || sidecar.permissionReplies[0].pending.ID != "perm_first" { t.Fatalf("first answer should be recorded once: %#v", sidecar.permissionReplies) } - // The dropped second prompt must not intercept the next ordinary message. + // On recovery the next ordinary message re-shows the kept second prompt + // and is itself still forwarded, not eaten as an answer. platform.replyFailures = 0 msg.Content = "what is the weather" if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } + if len(platform.replies) != 2 || !strings.Contains(platform.replies[1], "/repo/b.ts") { + t.Fatalf("second permission should be re-shown on recovery: %#v", platform.replies) + } if len(sidecar.permissionReplies) != 1 { t.Fatalf("ordinary message was intercepted as a permission answer: %#v", sidecar.permissionReplies) } if last := sidecar.prompts[len(sidecar.prompts)-1]; last.text != "what is the weather" { t.Fatalf("ordinary message was not forwarded as a prompt: %#v", sidecar.prompts) } + + // The re-shown second prompt is now answerable. + msg.Content = "no" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.permissionReplies) != 2 || sidecar.permissionReplies[1].pending.ID != "perm_second" { + t.Fatalf("re-shown second permission should be answerable: %#v", sidecar.permissionReplies) + } }) t.Run("question", func(t *testing.T) { @@ -845,7 +859,7 @@ func TestSurfacingNextBlockerReportsPersistentDeliveryFailure(t *testing.T) { platform.replyFailures = deliveryAttempts msg.Content = "1" if err := engine.HandleMessage(context.Background(), platform, msg); err == nil { - t.Fatal("a persistently failing next prompt must be reported to the caller") + t.Fatal("a failed surfacing of the next prompt must be reported to the caller") } if len(sidecar.questionReplies) != 1 || sidecar.questionReplies[0].pending.MessageID != "msg_first" { t.Fatalf("first answer should be recorded once: %#v", sidecar.questionReplies) @@ -856,12 +870,23 @@ func TestSurfacingNextBlockerReportsPersistentDeliveryFailure(t *testing.T) { if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { t.Fatal(err) } + if len(platform.replies) != 2 || !strings.Contains(platform.replies[1], "Pick two") { + t.Fatalf("second question should be re-shown on recovery: %#v", platform.replies) + } if len(sidecar.questionReplies) != 1 { t.Fatalf("ordinary message was intercepted as a question answer: %#v", sidecar.questionReplies) } if last := sidecar.prompts[len(sidecar.prompts)-1]; last.text != "hello there" { t.Fatalf("ordinary message was not forwarded as a prompt: %#v", sidecar.prompts) } + + msg.Content = "1" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if len(sidecar.questionReplies) != 2 || sidecar.questionReplies[1].pending.MessageID != "msg_second" { + t.Fatalf("re-shown second question should be answerable: %#v", sidecar.questionReplies) + } }) } From 0920ad8973e16933214d35e767726fe4e707d0f8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 22:56:16 +0800 Subject: [PATCH 18/24] fix(remote-bridge): reconcile undecodable critical events without wedging the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permission/question/session event that failed to decode was logged and skipped, recovered only at the next reconnect hydrate — an unbounded blind window for a safety-critical confirmation on a healthy long-lived stream. Mark such decode failures repairable; parseSSE advances the cursor past the bad frame first (so a reconnect can never replay it and wedge the global stream), then hydrates immediately to reconcile from the REST list endpoints. Assistant text (no REST backstop) and resolved events (hydrate cannot clear them) stay non-repairable. --- .../remote-bridge/internal/pawwork/events.go | 67 ++++++++++++++++--- .../internal/pawwork/events_test.go | 29 ++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/packages/remote-bridge/internal/pawwork/events.go b/packages/remote-bridge/internal/pawwork/events.go index 9c69ec7ef..357b4b09d 100644 --- a/packages/remote-bridge/internal/pawwork/events.go +++ b/packages/remote-bridge/internal/pawwork/events.go @@ -27,6 +27,13 @@ type ReplayRefreshHandler interface { HandleReplayRefresh(context.Context) error } +// eventRepairRefreshHandler reconciles state after an undecodable critical +// event was skipped. Unlike HandleReplayRefresh it runs on a live (non- +// reconnecting) stream too, since the skip can happen mid-stream. +type eventRepairRefreshHandler interface { + HandleEventRepairRefresh(context.Context) error +} + type StreamReadyHandler interface { HandleStreamReady(context.Context) error } @@ -70,14 +77,22 @@ func (h clientEventHandler) HandleReplayRefresh(ctx context.Context) error { if !h.reconnecting { return nil } + if err := h.hydrateNext(ctx); err != nil { + return replayRefreshError{err: err} + } + return nil +} + +func (h clientEventHandler) HandleEventRepairRefresh(ctx context.Context) error { + return h.hydrateNext(ctx) +} + +func (h clientEventHandler) hydrateNext(ctx context.Context) error { next, ok := h.next.(ReplayRefreshHandler) if !ok { return nil } - if err := next.HandleReplayRefresh(ctx); err != nil { - return replayRefreshError{err: err} - } - return nil + return next.HandleReplayRefresh(ctx) } type replayRefreshError struct { @@ -87,6 +102,19 @@ type replayRefreshError struct { func (e replayRefreshError) Error() string { return e.err.Error() } func (e replayRefreshError) Unwrap() error { return e.err } +// repairableEventDecodeError marks a critical event (permission/question/ +// session) that failed to decode but whose state can be reconciled from the +// REST list endpoints. parseSSE skips it, advances the cursor, then hydrates, +// so a single bad frame neither wedges the global stream nor silently hides a +// pending confirmation until the next reconnect. +type repairableEventDecodeError struct { + eventType string + err error +} + +func (e repairableEventDecodeError) Error() string { return e.eventType + ": " + e.err.Error() } +func (e repairableEventDecodeError) Unwrap() error { return e.err } + func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/global/event", nil) if err != nil { @@ -148,7 +176,7 @@ func DispatchEvent(ctx context.Context, data []byte, handler EventHandler) error case "permission.asked": var permission bridge.PendingPermission if err := json.Unmarshal(envelope.Payload.Properties, &permission); err != nil { - return err + return repairableEventDecodeError{eventType: "permission.asked", err: err} } if permission.ID == "" || permission.SessionID == "" { return nil @@ -166,14 +194,17 @@ func DispatchEvent(ctx context.Context, data []byte, handler EventHandler) error return handler.HandlePermissionResolved(ctx, resolution) case "session.created": session, ok, err := sessionFromEvent(envelope.Payload.Properties, envelope.Directory) - if err != nil || !ok { - return err + if err != nil { + return repairableEventDecodeError{eventType: "session.created", err: err} + } + if !ok { + return nil } return handler.HandleSession(ctx, session) case "message.part.updated": question, questionPending, resolution, questionResolved, err := questionUpdateFromEvent(envelope.Payload.Properties, envelope.Directory) if err != nil { - return err + return repairableEventDecodeError{eventType: "message.part.updated", err: err} } if questionPending { return handler.HandleQuestion(ctx, question) @@ -197,13 +228,20 @@ func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLa var data strings.Builder var eventID string flush := func() error { + reconcile := false if data.Len() > 0 { if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { var refresh replayRefreshError - if errors.As(err, &refresh) { + var repair repairableEventDecodeError + switch { + case errors.As(err, &refresh): return err + case errors.As(err, &repair): + slog.Warn("remote bridge reconciling after undecodable event", "type", repair.eventType, "error", repair.err) + reconcile = true + default: + slog.Warn("remote bridge ignored event", "error", err) } - slog.Warn("remote bridge ignored event", "error", err) } data.Reset() } @@ -213,6 +251,15 @@ func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLa } eventID = "" } + // Reconcile only after the cursor has advanced past the skipped event, so + // a failing hydrate can never replay the bad event and wedge the stream. + if reconcile { + if refresher, ok := handler.(eventRepairRefreshHandler); ok { + if err := refresher.HandleEventRepairRefresh(ctx); err != nil { + slog.Warn("remote bridge reconcile after undecodable event failed", "error", err) + } + } + } return nil } for scanner.Scan() { diff --git a/packages/remote-bridge/internal/pawwork/events_test.go b/packages/remote-bridge/internal/pawwork/events_test.go index bf53e0ae1..c8b9def3d 100644 --- a/packages/remote-bridge/internal/pawwork/events_test.go +++ b/packages/remote-bridge/internal/pawwork/events_test.go @@ -409,3 +409,32 @@ func TestClientStreamsLongCompletedText(t *testing.T) { t.Fatalf("texts = %#v", handler.texts) } } + +func TestClientReconcilesAfterUndecodableCriticalEvent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "text/event-stream") + // permission.asked whose patterns is a string, not an array: the critical + // event fails to decode and must be reconciled, not silently skipped. + _, _ = w.Write([]byte("id: evt-7\ndata: {\"payload\":{\"type\":\"permission.asked\",\"properties\":{\"id\":\"perm_1\",\"sessionID\":\"ses_1\",\"patterns\":\"oops\"}}}\n\n")) + })) + defer server.Close() + + handler := &fakeEventHandler{} + client := New(server.URL) + if err := client.StreamEvents(t.Context(), handler); err != nil { + t.Fatal(err) + } + if len(handler.permissions) != 0 { + t.Fatalf("undecodable permission must not be surfaced: %#v", handler.permissions) + } + // The cursor advances past the bad event so a reconnect can never replay it + // and wedge the global stream. + if got := client.lastEventIDValue(); got != "evt-7" { + t.Fatalf("cursor should advance past the skipped event, got %q", got) + } + // State is reconciled immediately via hydrate, even on a live (non- + // reconnecting) stream, instead of waiting for the next reconnect. + if handler.refreshes != 1 { + t.Fatalf("expected one reconcile after the undecodable event, got %d", handler.refreshes) + } +} From be6c43d536e51133a92e65b46d765c6b5e50d022 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 22:56:22 +0800 Subject: [PATCH 19/24] test(remote-bridge): pin audience contract and de-race run-loop tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single wildcard-Feishu case with a table covering the hasRemoteAudience contract: specific audience accepted, wildcard/empty/blank rejected, Feishu/Lark group_only both ways, allow_chat ignored for other platforms. Also make the run-loop tests' request counters atomic — they were written in the httptest handler goroutine and read in the test body, a pre-existing data race that -race intermittently catches. --- .../internal/gateway/gateway_test.go | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go index 9938285fd..4fb9c7167 100644 --- a/packages/remote-bridge/internal/gateway/gateway_test.go +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -103,9 +103,30 @@ func TestNewRejectsWildcardRemoteAudience(t *testing.T) { } } -func TestRemoteAudienceRejectsWildcardFeishuChat(t *testing.T) { - if hasRemoteAudience("feishu", map[string]any{"allow_chat": "*", "group_only": true}) { - t.Fatal("wildcard Feishu chat audience should not be accepted") +func TestHasRemoteAudience(t *testing.T) { + cases := []struct { + name string + platform string + options map[string]any + want bool + }{ + {"specific allow_from accepted", "slack", map[string]any{"allow_from": "C123"}, true}, + {"wildcard allow_from rejected", "slack", map[string]any{"allow_from": "*"}, false}, + {"empty allow_from rejected", "slack", map[string]any{"allow_from": ""}, false}, + {"blank allow_from rejected", "slack", map[string]any{"allow_from": " "}, false}, + {"missing audience rejected", "slack", map[string]any{}, false}, + {"feishu group chat accepted", "feishu", map[string]any{"allow_chat": "oc_1", "group_only": true}, true}, + {"lark group chat accepted", "lark", map[string]any{"allow_chat": "oc_1", "group_only": true}, true}, + {"feishu chat without group_only rejected", "feishu", map[string]any{"allow_chat": "oc_1"}, false}, + {"feishu wildcard chat rejected", "feishu", map[string]any{"allow_chat": "*", "group_only": true}, false}, + {"allow_chat ignored for non-lark platforms", "slack", map[string]any{"allow_chat": "oc_1", "group_only": true}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hasRemoteAudience(tc.platform, tc.options); got != tc.want { + t.Fatalf("hasRemoteAudience(%q, %#v) = %v, want %v", tc.platform, tc.options, got, tc.want) + } + }) } } @@ -390,15 +411,14 @@ func TestRunRetriesTransientEventStreamErrors(t *testing.T) { core.RegisterPlatform(platformName, func(map[string]any) (core.Platform, error) { return &fakePlatform{name: platformName}, nil }) - eventRequests := 0 + var eventRequests atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/experimental/session", "/permission", "/external-result": w.Header().Set("content-type", "application/json") _, _ = w.Write([]byte(`[]`)) case "/global/event": - eventRequests++ - if eventRequests == 1 { + if eventRequests.Add(1) == 1 { http.Error(w, "temporary", http.StatusInternalServerError) return } @@ -429,8 +449,8 @@ func TestRunRetriesTransientEventStreamErrors(t *testing.T) { if err := app.Run(ctx); err != nil { t.Fatal(err) } - if eventRequests < 2 { - t.Fatalf("event requests = %d", eventRequests) + if eventRequests.Load() < 2 { + t.Fatalf("event requests = %d", eventRequests.Load()) } } @@ -439,21 +459,20 @@ func TestRunHydratesAfterReplayGapSignal(t *testing.T) { core.RegisterPlatform(platformName, func(map[string]any) (core.Platform, error) { return &fakePlatform{name: platformName}, nil }) - eventRequests := 0 - permissionRequests := 0 + var eventRequests atomic.Int64 + var permissionRequests atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/experimental/session", "/external-result": w.Header().Set("content-type", "application/json") _, _ = w.Write([]byte(`[]`)) case "/permission": - permissionRequests++ + permissionRequests.Add(1) w.Header().Set("content-type", "application/json") _, _ = w.Write([]byte(`[]`)) case "/global/event": - eventRequests++ w.Header().Set("content-type", "text/event-stream") - if eventRequests == 1 { + if eventRequests.Add(1) == 1 { _, _ = w.Write([]byte("id: cursor-1\ndata: {\"payload\":{\"type\":\"server.connected\",\"properties\":{}}}\n\n")) return } @@ -486,8 +505,8 @@ func TestRunHydratesAfterReplayGapSignal(t *testing.T) { if err := app.Run(ctx); err != nil { t.Fatal(err) } - if permissionRequests < 2 { - t.Fatalf("permission requests = %d", permissionRequests) + if permissionRequests.Load() < 2 { + t.Fatalf("permission requests = %d", permissionRequests.Load()) } } From bf784d0958acd70d1c8d5b448f08674a63ed824b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 15 Jun 2026 23:13:39 +0800 Subject: [PATCH 20/24] fix(remote-bridge): reconcile critical events missing required fields A permission.asked without id/sessionID, a session.created without info.id, and a question tool event without sessionID/messageID/callID decoded fine but were silently skipped, so their state waited for the next reconnect hydrate. Treat these missing-field cases as repairable like a decode failure: advance the cursor past the frame, then hydrate immediately. Rename the marker type to repairableEventError since it now covers validation, not just decoding. --- .../remote-bridge/internal/pawwork/events.go | 37 +++++++++------ .../internal/pawwork/events_test.go | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/packages/remote-bridge/internal/pawwork/events.go b/packages/remote-bridge/internal/pawwork/events.go index 357b4b09d..1eee74807 100644 --- a/packages/remote-bridge/internal/pawwork/events.go +++ b/packages/remote-bridge/internal/pawwork/events.go @@ -102,18 +102,25 @@ type replayRefreshError struct { func (e replayRefreshError) Error() string { return e.err.Error() } func (e replayRefreshError) Unwrap() error { return e.err } -// repairableEventDecodeError marks a critical event (permission/question/ -// session) that failed to decode but whose state can be reconciled from the -// REST list endpoints. parseSSE skips it, advances the cursor, then hydrates, -// so a single bad frame neither wedges the global stream nor silently hides a -// pending confirmation until the next reconnect. -type repairableEventDecodeError struct { +// repairableEventError marks a critical event (permission/question/session) +// that could not be turned into actionable state — it failed to decode, or it +// decoded but lacks a required field — yet whose state can be reconciled from +// the REST list endpoints. parseSSE skips it, advances the cursor, then +// hydrates, so a single bad frame neither wedges the global stream nor silently +// hides a pending confirmation until the next reconnect. +type repairableEventError struct { eventType string err error } -func (e repairableEventDecodeError) Error() string { return e.eventType + ": " + e.err.Error() } -func (e repairableEventDecodeError) Unwrap() error { return e.err } +func (e repairableEventError) Error() string { return e.eventType + ": " + e.err.Error() } +func (e repairableEventError) Unwrap() error { return e.err } + +var ( + errMissingPermissionFields = errors.New("missing id or sessionID") + errMissingSessionID = errors.New("missing info.id") + errMissingQuestionFields = errors.New("missing sessionID, messageID, or callID") +) func (c *Client) StreamEvents(ctx context.Context, handler EventHandler) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/global/event", nil) @@ -176,10 +183,10 @@ func DispatchEvent(ctx context.Context, data []byte, handler EventHandler) error case "permission.asked": var permission bridge.PendingPermission if err := json.Unmarshal(envelope.Payload.Properties, &permission); err != nil { - return repairableEventDecodeError{eventType: "permission.asked", err: err} + return repairableEventError{eventType: "permission.asked", err: err} } if permission.ID == "" || permission.SessionID == "" { - return nil + return repairableEventError{eventType: "permission.asked", err: errMissingPermissionFields} } permission.Directory = envelope.Directory return handler.HandlePermission(ctx, permission) @@ -195,16 +202,16 @@ func DispatchEvent(ctx context.Context, data []byte, handler EventHandler) error case "session.created": session, ok, err := sessionFromEvent(envelope.Payload.Properties, envelope.Directory) if err != nil { - return repairableEventDecodeError{eventType: "session.created", err: err} + return repairableEventError{eventType: "session.created", err: err} } if !ok { - return nil + return repairableEventError{eventType: "session.created", err: errMissingSessionID} } return handler.HandleSession(ctx, session) case "message.part.updated": question, questionPending, resolution, questionResolved, err := questionUpdateFromEvent(envelope.Payload.Properties, envelope.Directory) if err != nil { - return repairableEventDecodeError{eventType: "message.part.updated", err: err} + return repairableEventError{eventType: "message.part.updated", err: err} } if questionPending { return handler.HandleQuestion(ctx, question) @@ -232,7 +239,7 @@ func parseSSE(ctx context.Context, reader io.Reader, handler EventHandler, setLa if data.Len() > 0 { if err := DispatchEvent(ctx, []byte(data.String()), handler); err != nil { var refresh replayRefreshError - var repair repairableEventDecodeError + var repair repairableEventError switch { case errors.As(err, &refresh): return err @@ -385,7 +392,7 @@ func questionUpdateFromEvent(data json.RawMessage, directory string) ( return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil } if part.SessionID == "" || part.MessageID == "" || part.CallID == "" { - return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, nil + return bridge.PendingQuestion{}, false, bridge.QuestionResolution{}, false, errMissingQuestionFields } resolution := bridge.QuestionResolution{ SessionID: part.SessionID, diff --git a/packages/remote-bridge/internal/pawwork/events_test.go b/packages/remote-bridge/internal/pawwork/events_test.go index c8b9def3d..b9b00b75a 100644 --- a/packages/remote-bridge/internal/pawwork/events_test.go +++ b/packages/remote-bridge/internal/pawwork/events_test.go @@ -438,3 +438,49 @@ func TestClientReconcilesAfterUndecodableCriticalEvent(t *testing.T) { t.Fatalf("expected one reconcile after the undecodable event, got %d", handler.refreshes) } } + +func TestClientReconcilesAfterIncompleteCriticalEvent(t *testing.T) { + // Valid JSON, but a required field is absent: the event still cannot become + // actionable state, so it must be reconciled, not silently skipped. + cases := []struct { + name string + data string + }{ + { + name: "permission missing id", + data: `{"payload":{"type":"permission.asked","properties":{"sessionID":"ses_1","permission":"edit"}}}`, + }, + { + name: "session missing info.id", + data: `{"payload":{"type":"session.created","properties":{"info":{"title":"child"}}}}`, + }, + { + name: "question missing ids", + data: `{"payload":{"type":"message.part.updated","properties":{"part":{"type":"tool","tool":"question","state":{"status":"running","metadata":{"externalResultReady":true}}}}}}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "text/event-stream") + _, _ = w.Write([]byte("id: evt-9\ndata: " + tc.data + "\n\n")) + })) + defer server.Close() + + handler := &fakeEventHandler{} + client := New(server.URL) + if err := client.StreamEvents(t.Context(), handler); err != nil { + t.Fatal(err) + } + if n := len(handler.permissions) + len(handler.sessions) + len(handler.questions); n != 0 { + t.Fatalf("incomplete event must not be surfaced: perms=%#v sessions=%#v questions=%#v", handler.permissions, handler.sessions, handler.questions) + } + if got := client.lastEventIDValue(); got != "evt-9" { + t.Fatalf("cursor should advance past the skipped event, got %q", got) + } + if handler.refreshes != 1 { + t.Fatalf("expected one reconcile after the incomplete event, got %d", handler.refreshes) + } + }) + } +} From 7b45717517a849bb77cacf1f520b75f2e447e388 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 16 Jun 2026 00:13:35 +0800 Subject: [PATCH 21/24] fix(remote-bridge): reject parent cycles in SetParent A bad SetParent that points a session's ancestry back at itself would form a cycle. rootSession then returns the starting node instead of a true root, letting the cycled session bypass the duplicate-root guard and bind the wrong remote conversation on restore/delivery. Walk the prospective parent chain before mutating; reject and leave state unchanged when it already reaches the child. --- .../internal/bridge/session_pointers.go | 17 +++++++++++++++++ .../internal/bridge/session_pointers_test.go | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/remote-bridge/internal/bridge/session_pointers.go b/packages/remote-bridge/internal/bridge/session_pointers.go index cf1231c1f..b07219430 100644 --- a/packages/remote-bridge/internal/bridge/session_pointers.go +++ b/packages/remote-bridge/internal/bridge/session_pointers.go @@ -87,6 +87,9 @@ func (p *SessionPointersStore) SetParent(sessionID string, parentID string) erro } p.mu.Lock() defer p.mu.Unlock() + if parentChainReaches(p.parents, parentID, sessionID) { + return fmt.Errorf("session parent would create a cycle") + } if hasAnyRootConflict(p.sessions, withParent(p.parents, sessionID, parentID)) { return fmt.Errorf("session root is already bound to another remote conversation") } @@ -172,6 +175,20 @@ func rootSession(parents map[string]string, sessionID string) string { return sessionID } +// parentChainReaches reports whether walking the parent chain from start ever +// lands on target. SetParent uses it to reject a parentID whose ancestry +// already contains the child, which would otherwise form a cycle. +func parentChainReaches(parents map[string]string, start string, target string) bool { + seen := map[string]bool{} + for current := start; current != "" && !seen[current]; current = parents[current] { + if current == target { + return true + } + seen[current] = true + } + return false +} + func hasRootConflict(sessions map[string]string, parents map[string]string, remoteKey string, sessionID string) bool { root := rootSession(parents, sessionID) if root == "" { diff --git a/packages/remote-bridge/internal/bridge/session_pointers_test.go b/packages/remote-bridge/internal/bridge/session_pointers_test.go index cc3a7491a..c7cddc225 100644 --- a/packages/remote-bridge/internal/bridge/session_pointers_test.go +++ b/packages/remote-bridge/internal/bridge/session_pointers_test.go @@ -31,6 +31,24 @@ func TestMemorySessionPointersRejectParentThatCreatesDuplicateRoot(t *testing.T) } } +func TestMemorySessionPointersRejectParentCycle(t *testing.T) { + pointers := NewMemorySessionPointers() + if err := pointers.SetParent("ses_1", "ses_2"); err != nil { + t.Fatal(err) + } + + if err := pointers.SetParent("ses_2", "ses_1"); err == nil { + t.Fatal("expected parent binding to reject a cycle") + } + + if got := pointers.RootSession("ses_1"); got != "ses_2" { + t.Fatalf("root after rejected cycle = %q, want ses_2", got) + } + if got := pointers.RootSession("ses_2"); got != "ses_2" { + t.Fatalf("ses_2 should remain its own root, got %q", got) + } +} + func TestFileSessionPointersDoNotRestoreAmbiguousRootBindings(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") if err := os.WriteFile(path, []byte(`{ From edceaa36bd7e57e757cbb0cb882387369bfcbdc0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 16 Jun 2026 00:14:58 +0800 Subject: [PATCH 22/24] fix(remote-bridge): bound hydrate session scan hydrate fetched ListSessions(ctx, 0), which the client treats as unbounded and pulls the full session history on every startup and reconnect. Users with many sessions pay a slow warm-up. Parent links are restored from the persisted pointer store and live event replay, so the scan only needs the most recently active sessions. Cap it at hydrateSessionLimit (100). --- .../remote-bridge/internal/gateway/gateway.go | 7 ++- .../internal/gateway/gateway_test.go | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/remote-bridge/internal/gateway/gateway.go b/packages/remote-bridge/internal/gateway/gateway.go index e1c21f625..419e1c8b9 100644 --- a/packages/remote-bridge/internal/gateway/gateway.go +++ b/packages/remote-bridge/internal/gateway/gateway.go @@ -213,8 +213,13 @@ func (h replayRefreshHandler) HandleStreamReady(context.Context) error { return nil } +// hydrateSessionLimit bounds the warm-up scan on startup and reconnect. Parent +// links come from the persisted pointer store and live event replay, so the +// scan only needs the most recently active sessions, not the full history. +const hydrateSessionLimit = 100 + func (a *App) hydrate(ctx context.Context) error { - sessions, err := a.client.ListSessions(ctx, 0) + sessions, err := a.client.ListSessions(ctx, hydrateSessionLimit) if err != nil { return err } diff --git a/packages/remote-bridge/internal/gateway/gateway_test.go b/packages/remote-bridge/internal/gateway/gateway_test.go index 4fb9c7167..610eb9223 100644 --- a/packages/remote-bridge/internal/gateway/gateway_test.go +++ b/packages/remote-bridge/internal/gateway/gateway_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -208,6 +209,48 @@ func TestHydrateResurfacesPendingInteractions(t *testing.T) { } } +func TestHydrateRequestsBoundedSessionList(t *testing.T) { + platform := &fakePlatform{name: "runtime-test-hydrate-bounded"} + core.RegisterPlatform("runtime-test-hydrate-bounded", func(map[string]any) (core.Platform, error) { + return platform, nil + }) + var sessionLimit string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/experimental/session": + sessionLimit = r.URL.Query().Get("limit") + _, _ = w.Write([]byte(`[]`)) + case "/permission", "/external-result": + _, _ = w.Write([]byte(`[]`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + app, err := New(Config{ + PawWorkBaseURL: server.URL, + StatePath: filepath.Join(t.TempDir(), "sessions.json"), + Platforms: []PlatformConfig{{ + Name: "runtime-test-hydrate-bounded", + Enabled: true, + Options: map[string]any{"allow_from": "U123"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if err := app.hydrate(t.Context()); err != nil { + t.Fatal(err) + } + + if sessionLimit != strconv.Itoa(hydrateSessionLimit) { + t.Fatalf("hydrate session limit = %q, want %d", sessionLimit, hydrateSessionLimit) + } +} + func TestRunStartsEventStreamBeforePlatforms(t *testing.T) { platform := &fakePlatform{name: "runtime-test-stream-before-platform"} core.RegisterPlatform("runtime-test-stream-before-platform", func(map[string]any) (core.Platform, error) { From 3316a4553616f92e424f4598db5ba39e99292227 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 16 Jun 2026 00:56:39 +0800 Subject: [PATCH 23/24] fix(remote-bridge): resolve /sessions N against the live list The picker cached the listed sessions and /sessions N reused that cache whenever it was non-empty. A delayed pick then switched to a session from a stale snapshot instead of the current recent list. Drop the picker cache entirely and re-fetch the bounded session list in switchSession, so N always resolves against what is live now. This also removes per-conversation in-memory state that was never cleared. --- .../remote-bridge/internal/bridge/engine.go | 36 ++++--------------- .../internal/bridge/engine_test.go | 29 +++++++++++++++ 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/engine.go b/packages/remote-bridge/internal/bridge/engine.go index 46b938caf..7f9955f14 100644 --- a/packages/remote-bridge/internal/bridge/engine.go +++ b/packages/remote-bridge/internal/bridge/engine.go @@ -91,7 +91,6 @@ type Engine struct { mu sync.Mutex sidecar Sidecar pointers SessionPointers - pickers map[string][]Session active map[string]delivery platforms map[string]core.Platform permissions map[string]PendingPermission @@ -135,7 +134,6 @@ func NewWithSessionPointers(sidecar Sidecar, pointers SessionPointers) *Engine { return &Engine{ sidecar: sidecar, pointers: pointers, - pickers: make(map[string][]Session), active: make(map[string]delivery), platforms: make(map[string]core.Platform), permissions: make(map[string]PendingPermission), @@ -644,10 +642,8 @@ func (e *Engine) replySessionPicker(ctx context.Context, platform core.Platform, return err } if len(sessions) == 0 { - e.clearPicker(key) return platform.Reply(ctx, msg.ReplyCtx, "No recent PawWork sessions.") } - e.setPicker(key, sessions) var out strings.Builder out.WriteString("Recent PawWork sessions:") for index, session := range sessions { @@ -665,13 +661,13 @@ func (e *Engine) switchSession(ctx context.Context, platform core.Platform, msg if err != nil || index < 1 { return platform.Reply(ctx, msg.ReplyCtx, "Choose a session with /sessions 1.") } - sessions := e.picker(key) - if len(sessions) == 0 { - sessions, err = e.sidecar.ListSessions(ctx, 5) - if err != nil { - _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not list sessions: "+err.Error()) - return err - } + // Fetch the current list rather than trusting a cached picker: between + // listing and picking, the recent sessions may have changed, and N must + // resolve against what is live now, not a stale snapshot. + sessions, err := e.sidecar.ListSessions(ctx, 5) + if err != nil { + _ = platform.Reply(ctx, msg.ReplyCtx, "PawWork could not list sessions: "+err.Error()) + return err } if index > len(sessions) { return platform.Reply(ctx, msg.ReplyCtx, fmt.Sprintf("Only %d recent PawWork sessions are available.", len(sessions))) @@ -689,24 +685,6 @@ func (e *Engine) switchSession(ctx context.Context, platform core.Platform, msg return platform.Reply(ctx, msg.ReplyCtx, "Switched to "+sessionLabel(session)+".") } -func (e *Engine) picker(remoteKey string) []Session { - e.mu.Lock() - defer e.mu.Unlock() - return e.pickers[remoteKey] -} - -func (e *Engine) setPicker(remoteKey string, sessions []Session) { - e.mu.Lock() - defer e.mu.Unlock() - e.pickers[remoteKey] = sessions -} - -func (e *Engine) clearPicker(remoteKey string) { - e.mu.Lock() - defer e.mu.Unlock() - delete(e.pickers, remoteKey) -} - func (e *Engine) setCurrent(remoteKey string, sessionID string) error { return e.pointers.Set(remoteKey, sessionID) } diff --git a/packages/remote-bridge/internal/bridge/engine_test.go b/packages/remote-bridge/internal/bridge/engine_test.go index 4593fb191..dd7306693 100644 --- a/packages/remote-bridge/internal/bridge/engine_test.go +++ b/packages/remote-bridge/internal/bridge/engine_test.go @@ -198,6 +198,35 @@ func TestEngineListsAndSwitchesRecentSessions(t *testing.T) { } } +func TestEngineSwitchResolvesAgainstCurrentSessions(t *testing.T) { + sidecar := &fakeSidecar{ + sessions: []Session{ + {ID: "ses_a", Title: "Plan launch"}, + {ID: "ses_b", Title: "Fix importer"}, + }, + } + platform := &fakePlatform{} + engine := New(sidecar) + msg := &core.Message{SessionKey: "slack:dm:bob", Content: "/sessions"} + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + + // The recent list changes before the user picks a number. + sidecar.sessions = []Session{ + {ID: "ses_c", Title: "Triage bug"}, + {ID: "ses_d", Title: "Write docs"}, + } + + msg.Content = "/sessions 2" + if err := engine.HandleMessage(context.Background(), platform, msg); err != nil { + t.Fatal(err) + } + if got := engine.CurrentSession("slack:dm:bob"); got != "ses_d" { + t.Fatalf("switch used a stale picker: current session = %q, want ses_d", got) + } +} + func TestEngineRejectsSwitchingToChildOfAnotherRemoteRoot(t *testing.T) { sidecar := &fakeSidecar{ sessions: []Session{ From 708d7584cb85319e0446e2bb8d04b8a2417ecd58 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 16 Jun 2026 00:59:36 +0800 Subject: [PATCH 24/24] fix(remote-bridge): write state through a unique temp file saveLocked always wrote a fixed .tmp before renaming. Two bridge processes sharing one state path then race on that single temp: one renames it away before the other's rename, failing the second write with ENOENT (clobbering cursor/session updates). Write each snapshot through os.CreateTemp in the same directory and rename it into place, with a deferred cleanup that is a no-op once the rename succeeds. The in-process mutex is unchanged. --- .../internal/bridge/session_pointers.go | 20 ++++++- .../internal/bridge/session_pointers_test.go | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/remote-bridge/internal/bridge/session_pointers.go b/packages/remote-bridge/internal/bridge/session_pointers.go index b07219430..6618c9782 100644 --- a/packages/remote-bridge/internal/bridge/session_pointers.go +++ b/packages/remote-bridge/internal/bridge/session_pointers.go @@ -136,7 +136,8 @@ func (p *SessionPointersStore) saveLocked() error { if p.path == "" { return nil } - if err := os.MkdirAll(filepath.Dir(p.path), 0o700); err != nil { + dir := filepath.Dir(p.path) + if err := os.MkdirAll(dir, 0o700); err != nil { return err } data, err := json.MarshalIndent(struct { @@ -151,8 +152,21 @@ func (p *SessionPointersStore) saveLocked() error { if err != nil { return err } - tempPath := p.path + ".tmp" - if err := os.WriteFile(tempPath, data, 0o600); err != nil { + // Write through a unique temp file so a second process sharing this path + // cannot clobber a fixed .tmp mid-write. Each writer renames its own + // complete snapshot into place; the deferred remove cleans up on error and + // is a no-op once the rename succeeds. + temp, err := os.CreateTemp(dir, filepath.Base(p.path)+".*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { return err } return os.Rename(tempPath, p.path) diff --git a/packages/remote-bridge/internal/bridge/session_pointers_test.go b/packages/remote-bridge/internal/bridge/session_pointers_test.go index c7cddc225..43f0aa84d 100644 --- a/packages/remote-bridge/internal/bridge/session_pointers_test.go +++ b/packages/remote-bridge/internal/bridge/session_pointers_test.go @@ -1,8 +1,10 @@ package bridge import ( + "fmt" "os" "path/filepath" + "sync" "testing" ) @@ -72,6 +74,60 @@ func TestFileSessionPointersDoNotRestoreAmbiguousRootBindings(t *testing.T) { } } +func TestFileSessionPointersConcurrentWritesLeaveValidState(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + first, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + second, err := NewFileSessionPointers(path) + if err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var writeErrs []error + record := func(err error) { + if err == nil { + return + } + mu.Lock() + writeErrs = append(writeErrs, err) + mu.Unlock() + } + + var wg sync.WaitGroup + for i, store := range []*SessionPointersStore{first, second} { + wg.Add(1) + go func(store *SessionPointersStore, id int) { + defer wg.Done() + for n := 0; n < 200; n++ { + record(store.Set(fmt.Sprintf("slack:dm:%d-%d", id, n), fmt.Sprintf("ses_%d_%d", id, n))) + record(store.SetEventCursor(fmt.Sprintf("cursor-%d-%d", id, n))) + } + }(store, i) + } + wg.Wait() + + // A fixed .tmp lets one writer rename the shared temp away before the + // other's rename, failing the second write with ENOENT. A unique temp per + // write removes that contention. + if len(writeErrs) != 0 { + t.Fatalf("concurrent writes failed %d times, first: %v", len(writeErrs), writeErrs[0]) + } + // The surviving file must still be a complete, parseable snapshot. + if _, err := NewFileSessionPointers(path); err != nil { + t.Fatalf("state file is unreadable after concurrent writes: %v", err) + } + leftovers, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(leftovers) != 0 { + t.Fatalf("temp files left behind: %v", leftovers) + } +} + func TestFileSessionPointersPersistEventCursorWithSessions(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") pointers, err := NewFileSessionPointers(path)