From cbe800aa28a84e551eef0cd2e77fc9a44985ce2a Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 11 Sep 2026 17:40:01 +0800 Subject: [PATCH] feat(devin): bind upstream session_id and cascade_id to CPA canonical session --- internal/runtime/executor/devin_executor.go | 47 +++++++++++++++++-- .../runtime/executor/devin_executor_test.go | 41 ++++++++++++++++ internal/runtime/executor/helps/devin_wire.go | 3 ++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index e17120428..4be14c277 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -234,6 +234,7 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr payload = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatInteractions, req.Model, payload, opts.Stream) } systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest) + sessionID, cascadeID = resolveDevinSessionAndCascadeIDs(ctx, sessionID, cascadeID, opts) chatModelUID := helps.ResolveDevinChatModelUID(req.Model, thinkingLevel, budgetTokens) @@ -696,9 +697,20 @@ func parseInteractionsPayload(payload, originalRequest []byte) ( } // 3. Session and Cascade ID - sessionID = strings.TrimSpace(root.Get("previous_interaction_id").String()) - if sessionID == "" { - sessionID = uuid.New().String() + sessionID = strings.TrimSpace(firstNonEmpty( + root.Get("previous_interaction_id").String(), + root.Get("session_id").String(), + root.Get("sessionId").String(), + root.Get("conversation_id").String(), + )) + if sessionID == "" && len(originalRequest) > 0 { + origRoot := gjson.ParseBytes(originalRequest) + sessionID = strings.TrimSpace(firstNonEmpty( + origRoot.Get("previous_interaction_id").String(), + origRoot.Get("session_id").String(), + origRoot.Get("sessionId").String(), + origRoot.Get("conversation_id").String(), + )) } cascadeID = sessionID @@ -1206,6 +1218,35 @@ func devinAuthLogFields(auth *cliproxyauth.Auth) (authID, authLabel, authType, a return } +func resolveDevinSessionAndCascadeIDs(ctx context.Context, sessionID, cascadeID string, opts cliproxyexecutor.Options) (string, string) { + if sessionID == "" { + if ctxSession := util.SessionIDFromContext(ctx); ctxSession != "" { + sessionID = ctxSession + } else if canon := cliproxyauth.CanonicalSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata); canon != "" { + sessionID = canon + } + } + sessionID = normalizeDevinUUID(sessionID) + if cascadeID == "" { + cascadeID = sessionID + } else { + cascadeID = normalizeDevinUUID(cascadeID) + } + return sessionID, cascadeID +} + +func normalizeDevinUUID(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return uuid.New().String() + } + if _, err := uuid.Parse(raw); err == nil { + return raw + } + // Deterministically map any non-UUID session string (e.g. lcp:hash, conv:id) to an RFC 4122 UUID v5 + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(raw)).String() +} + func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index e3b3d5afd..566c65c5a 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -2,13 +2,16 @@ package executor import ( "bytes" + "context" "net/http" "strings" "testing" "time" + "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" @@ -240,6 +243,44 @@ func TestDevinStatusError_RetryAfter(t *testing.T) { } } +func TestResolveDevinSessionAndCascadeIDs(t *testing.T) { + // 1. Direct UUID preservation + rawUUID := "8176cf8a-feff-44c1-8e3e-b10f6d737ae1" + sid, cid := resolveDevinSessionAndCascadeIDs(context.Background(), rawUUID, rawUUID, cliproxyexecutor.Options{}) + if sid != rawUUID || cid != rawUUID { + t.Fatalf("sid/cid = %q/%q, want %q", sid, cid, rawUUID) + } + + // 2. Non-UUID mapping to deterministic UUID + sid1, cid1 := resolveDevinSessionAndCascadeIDs(context.Background(), "lcp:12345678", "", cliproxyexecutor.Options{}) + sid2, cid2 := resolveDevinSessionAndCascadeIDs(context.Background(), "lcp:12345678", "", cliproxyexecutor.Options{}) + if sid1 != sid2 || cid1 != cid2 { + t.Fatalf("deterministic mapping failed: %q != %q", sid1, sid2) + } + if _, err := uuid.Parse(sid1); err != nil { + t.Fatalf("mapped sid is not a valid UUID: %q", sid1) + } + + // 3. Fallback to ctx session + ctx := util.WithSessionID(context.Background(), "ctx-session-abc") + sidCtx, cidCtx := resolveDevinSessionAndCascadeIDs(ctx, "", "", cliproxyexecutor.Options{}) + if _, err := uuid.Parse(sidCtx); err != nil { + t.Fatalf("sidCtx is not a valid UUID: %q", sidCtx) + } + if sidCtx != cidCtx { + t.Fatalf("sidCtx %q != cidCtx %q", sidCtx, cidCtx) + } + + // 4. Fallback to fresh UUID when nothing supplied + sidEmpty, cidEmpty := resolveDevinSessionAndCascadeIDs(context.Background(), "", "", cliproxyexecutor.Options{}) + if _, err := uuid.Parse(sidEmpty); err != nil { + t.Fatalf("sidEmpty is not a valid UUID: %q", sidEmpty) + } + if sidEmpty != cidEmpty { + t.Fatalf("sidEmpty %q != cidEmpty %q", sidEmpty, cidEmpty) + } +} + func TestConsumeDevinFramesToInteractions(t *testing.T) { // Synthesize a Connect stream with 2 data frames and 1 EOS trailer var streamBuf bytes.Buffer diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go index bf21d19f7..ec2dcf493 100644 --- a/internal/runtime/executor/helps/devin_wire.go +++ b/internal/runtime/executor/helps/devin_wire.go @@ -406,6 +406,9 @@ func BuildDevinGetChatMessageRequest( f15Bytes = protowire.AppendTag(f15Bytes, 1, protowire.BytesType) f15Bytes = protowire.AppendString(f15Bytes, sessionID) + f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType) + f15Bytes = protowire.AppendVarint(f15Bytes, 53) + f15Bytes = protowire.AppendTag(f15Bytes, 3, protowire.VarintType) f15Bytes = protowire.AppendVarint(f15Bytes, 4)