@@ -2,6 +2,8 @@ package commandbridge
22
33import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "encoding/json"
68 "errors"
79 "fmt"
@@ -49,8 +51,27 @@ type LogoutCommandBuilder func() (adapterprocess.Spec, error)
4951
5052type AuthRequiredDetector func (adapterprocess.Result , error ) bool
5153
54+ type PromptFailureKind uint8
55+
56+ const (
57+ PromptFailureUnknown PromptFailureKind = iota
58+ PromptFailureNativeSessionMissing
59+ )
60+
61+ // PromptFailureClassifier maps a provider-specific non-zero command exit to a
62+ // provider-neutral wire discriminator. The bridge invokes it only for a
63+ // process.ExitError; classifiers must additionally fail closed on partial or
64+ // truncated output and prove provider-specific identity/session invariants.
65+ // The bridge reports the classification without retrying or mutating session
66+ // state; the host remains responsible for deciding whether replacing native
67+ // state is safe for its persisted session.
68+ type PromptFailureClassifier func (Session , adapterprocess.Spec , adapterprocess.Result , error ) PromptFailureKind
69+
5270type Spec struct {
53- Runner Runner
71+ Runner Runner
72+ // NewID overrides the default cryptographically random ACP session id.
73+ // Custom generators must return a non-empty id that remains unique across
74+ // adapter process restarts.
5475 NewID func () string
5576 LoadUnknownSessions bool
5677 AuthMethods []acp.AuthMethod
@@ -62,6 +83,7 @@ type Spec struct {
6283 BuildAuthenticate AuthenticateCommandBuilder
6384 BuildLogout LogoutCommandBuilder
6485 AuthRequired AuthRequiredDetector
86+ ClassifyPromptFailure PromptFailureClassifier
6587 NewStreamParser func (Session , runtimeacp.PromptParams ) StreamParser
6688 Now func () time.Time
6789}
@@ -232,7 +254,10 @@ func (b *Bridge) newSession(ctx *acp.MethodContext, params json.RawMessage) (any
232254 if rpcErr := decodeParams (params , & req ); rpcErr != nil {
233255 return nil , rpcErr
234256 }
235- id := b .newID ()
257+ id , err := b .newID ()
258+ if err != nil {
259+ return nil , & acp.RPCError {Code : - 32000 , Message : "session id generation failed" , Data : err .Error ()}
260+ }
236261 now := b .now ()
237262 state := & sessionState {Session : Session {
238263 ID : id ,
@@ -264,7 +289,10 @@ func (b *Bridge) forkSession(ctx *acp.MethodContext, params json.RawMessage) (an
264289 if rpcErr != nil {
265290 return nil , rpcErr
266291 }
267- id := b .newID ()
292+ id , err := b .newID ()
293+ if err != nil {
294+ return nil , & acp.RPCError {Code : - 32000 , Message : "session id generation failed" , Data : err .Error ()}
295+ }
268296 now := b .now ()
269297 state := & sessionState {Session : Session {
270298 ID : id ,
@@ -440,7 +468,11 @@ func (b *Bridge) prompt(ctx *acp.MethodContext, params json.RawMessage) (any, *a
440468 if b .authRequired (result , err ) {
441469 return nil , authRequired (commandErrorData (result , err ))
442470 }
443- return nil , & acp.RPCError {Code : - 32000 , Message : "prompt command failed" , Data : commandErrorData (result , err )}
471+ data := commandErrorData (result , err )
472+ if kind := b .classifyPromptFailure (state .Session , command , result , err ); kind != "" {
473+ data ["errorKind" ] = kind
474+ }
475+ return nil , & acp.RPCError {Code : - 32000 , Message : "prompt command failed" , Data : data }
444476 }
445477 b .recordPromptSuccess (req .SessionID )
446478 if info , ok := b .recordTranscriptExchange (req .SessionID , PromptText (req ), assistantText ); ok {
@@ -1075,11 +1107,19 @@ func (b *Bridge) cancel(sessionID string) bool {
10751107 return true
10761108}
10771109
1078- func (b * Bridge ) newID () string {
1110+ func (b * Bridge ) newID () ( string , error ) {
10791111 if b .spec .NewID != nil {
1080- return b .spec .NewID ()
1112+ id := b .spec .NewID ()
1113+ if strings .TrimSpace (id ) == "" {
1114+ return "" , errors .New ("custom session id is empty" )
1115+ }
1116+ return id , nil
1117+ }
1118+ var entropy [16 ]byte
1119+ if _ , err := rand .Read (entropy [:]); err != nil {
1120+ return "" , fmt .Errorf ("read secure session id entropy: %w" , err )
10811121 }
1082- return fmt . Sprintf ( "session-%d" , b . nextID . Add ( 1 ))
1122+ return "session-" + hex . EncodeToString ( entropy [:]), nil
10831123}
10841124
10851125func (b * Bridge ) now () time.Time {
@@ -1217,6 +1257,22 @@ func (b *Bridge) authRequired(result adapterprocess.Result, err error) bool {
12171257 return b != nil && b .spec .AuthRequired != nil && b .spec .AuthRequired (result , err )
12181258}
12191259
1260+ func (b * Bridge ) classifyPromptFailure (session Session , command adapterprocess.Spec , result adapterprocess.Result , err error ) string {
1261+ if b == nil || b .spec .ClassifyPromptFailure == nil {
1262+ return ""
1263+ }
1264+ var exitErr * adapterprocess.ExitError
1265+ if ! errors .As (err , & exitErr ) || exitErr .Code == 0 {
1266+ return ""
1267+ }
1268+ switch b .spec .ClassifyPromptFailure (session , command , result , err ) {
1269+ case PromptFailureNativeSessionMissing :
1270+ return "native_session_missing"
1271+ default :
1272+ return ""
1273+ }
1274+ }
1275+
12201276func commandErrorData (result adapterprocess.Result , err error ) map [string ]any {
12211277 data := map [string ]any {
12221278 "error" : err .Error (),
0 commit comments