Skip to content

Commit f238378

Browse files
committed
feat(commandbridge): classify missing native sessions
1 parent 38aeacc commit f238378

3 files changed

Lines changed: 316 additions & 14 deletions

File tree

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ Adapters have two runtime integration paths:
4444
publishing `session_info_update` notifications when transcript metadata
4545
changes, translating structured command streams into ACP updates when a
4646
parser is configured, requesting ACP tool permissions from parsed stream
47-
events before continuing, and optionally prepending a bounded transcript
48-
prelude to later prompt commands. Adapters for CLIs with native session ids
49-
may opt into adopting unknown `session/load` or `session/resume` ids so the
50-
provider command can continue a session known to the host after an adapter
51-
process restart. Command-backed adapters can also advertise ACP
47+
events before continuing, optionally mapping provider-specific missing native
48+
conversation failures to a typed `native_session_missing` prompt-error
49+
discriminator, and optionally prepending a bounded transcript prelude to
50+
later prompt commands.
51+
The host decides whether replacing that missing native conversation is safe;
52+
the kit never retries a prompt. The classifier runs only for a non-zero
53+
process exit; adapters must also reject partial or truncated output and bind
54+
provider-specific failures to the exact native session. Adapters for CLIs
55+
with native session ids may opt into adopting unknown `session/load` or
56+
`session/resume` ids so the provider command can continue a session known to
57+
the host after an adapter process restart. Command-backed adapters can also advertise ACP
5258
`session/delete` as destructive in-memory session cleanup, advertise ACP
5359
`authMethods`, run a fixed-argv native login command for `authenticate`, and
5460
advertise/run ACP `logout` when the provider CLI supports ending local auth.

commandbridge/bridge.go

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package commandbridge
22

33
import (
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

5052
type 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+
5270
type 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

10851125
func (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+
12201276
func commandErrorData(result adapterprocess.Result, err error) map[string]any {
12211277
data := map[string]any{
12221278
"error": err.Error(),

0 commit comments

Comments
 (0)