Unified · Split
| @@ -234,6 +234,7 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr · 233 unmodified lines | ||
| 234 | 234 | payload = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatInteractions, req.Model, payload, opts.Stream) |
| 235 | 235 | } |
| 236 | 236 | systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest) |
| 237 | + sessionID, cascadeID = resolveDevinSessionAndCascadeIDs(ctx, sessionID, cascadeID, opts) | |
| 237 | 238 | |
| 238 | 239 | chatModelUID := helps.ResolveDevinChatModelUID(req.Model, thinkingLevel, budgetTokens) |
| 239 | 240 | |
| @@ -696,9 +697,20 @@ func parseInteractionsPayload(payload, originalRequest []byte) ( · 456 unmodified lines | ||
| 696 | 697 | } |
| 697 | 698 | |
| 698 | 699 | // 3. Session and Cascade ID |
| 699 | − sessionID = strings.TrimSpace(root.Get("previous_interaction_id").String()) | |
| 700 | − if sessionID == "" { | |
| 701 | − sessionID = uuid.New().String() | |
| 700 | + sessionID = strings.TrimSpace(firstNonEmpty( | |
| 701 | + root.Get("previous_interaction_id").String(), | |
| 702 | + root.Get("session_id").String(), | |
| 703 | + root.Get("sessionId").String(), | |
| 704 | + root.Get("conversation_id").String(), | |
| 705 | + )) | |
| 706 | + if sessionID == "" && len(originalRequest) > 0 { | |
| 707 | + origRoot := gjson.ParseBytes(originalRequest) | |
| 708 | + sessionID = strings.TrimSpace(firstNonEmpty( | |
| 709 | + origRoot.Get("previous_interaction_id").String(), | |
| 710 | + origRoot.Get("session_id").String(), | |
| 711 | + origRoot.Get("sessionId").String(), | |
| 712 | + origRoot.Get("conversation_id").String(), | |
| 713 | + )) | |
| 702 | 714 | } |
| 703 | 715 | cascadeID = sessionID |
| 704 | 716 | |
| @@ -1206,6 +1218,35 @@ func devinAuthLogFields(auth *cliproxyauth.Auth) (authID, authLabel, authType, a · 501 unmodified lines | ||
| 1206 | 1218 | return |
| 1207 | 1219 | } |
| 1208 | 1220 | |
| 1221 | +func resolveDevinSessionAndCascadeIDs(ctx context.Context, sessionID, cascadeID string, opts cliproxyexecutor.Options) (string, string) { | |
| 1222 | + if sessionID == "" { | |
| 1223 | + if ctxSession := util.SessionIDFromContext(ctx); ctxSession != "" { | |
| 1224 | + sessionID = ctxSession | |
| 1225 | + } else if canon := cliproxyauth.CanonicalSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata); canon != "" { | |
| 1226 | + sessionID = canon | |
| 1227 | + } | |
| 1228 | + } | |
| 1229 | + sessionID = normalizeDevinUUID(sessionID) | |
| 1230 | + if cascadeID == "" { | |
| 1231 | + cascadeID = sessionID | |
| 1232 | + } else { | |
| 1233 | + cascadeID = normalizeDevinUUID(cascadeID) | |
| 1234 | + } | |
| 1235 | + return sessionID, cascadeID | |
| 1236 | +} | |
| 1237 | + | |
| 1238 | +func normalizeDevinUUID(raw string) string { | |
| 1239 | + raw = strings.TrimSpace(raw) | |
| 1240 | + if raw == "" { | |
| 1241 | + return uuid.New().String() | |
| 1242 | + } | |
| 1243 | + if _, err := uuid.Parse(raw); err == nil { | |
| 1244 | + return raw | |
| 1245 | + } | |
| 1246 | + // Deterministically map any non-UUID session string (e.g. lcp:hash, conv:id) to an RFC 4122 UUID v5 | |
| 1247 | + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(raw)).String() | |
| 1248 | +} | |
| 1249 | + | |
| 1209 | 1250 | func firstNonEmpty(values ...string) string { |
| 1210 | 1251 | for _, v := range values { |
| 1211 | 1252 | if strings.TrimSpace(v) != "" { |
| @@ -2,13 +2,16 @@ package executor · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | + "context" | |
| 5 | 6 | "net/http" |
| 6 | 7 | "strings" |
| 7 | 8 | "testing" |
| 8 | 9 | "time" |
| 9 | 10 | |
| 11 | + "github.com/google/uuid" | |
| 10 | 12 | "github.com/router-for-me/CLIProxyAPI/v7/internal/config" |
| 11 | 13 | "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" |
| 14 | + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" | |
| 12 | 15 | cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" |
| 13 | 16 | cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" |
| 14 | 17 | sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" |
| @@ -240,6 +243,44 @@ func TestDevinStatusError_RetryAfter(t *testing.T) { · 225 unmodified lines | ||
| 240 | 243 | } |
| 241 | 244 | } |
| 242 | 245 | |
| 246 | +func TestResolveDevinSessionAndCascadeIDs(t *testing.T) { | |
| 247 | + // 1. Direct UUID preservation | |
| 248 | + rawUUID := "8176cf8a-feff-44c1-8e3e-b10f6d737ae1" | |
| 249 | + sid, cid := resolveDevinSessionAndCascadeIDs(context.Background(), rawUUID, rawUUID, cliproxyexecutor.Options{}) | |
| 250 | + if sid != rawUUID || cid != rawUUID { | |
| 251 | + t.Fatalf("sid/cid = %q/%q, want %q", sid, cid, rawUUID) | |
| 252 | + } | |
| 253 | + | |
| 254 | + // 2. Non-UUID mapping to deterministic UUID | |
| 255 | + sid1, cid1 := resolveDevinSessionAndCascadeIDs(context.Background(), "lcp:12345678", "", cliproxyexecutor.Options{}) | |
| 256 | + sid2, cid2 := resolveDevinSessionAndCascadeIDs(context.Background(), "lcp:12345678", "", cliproxyexecutor.Options{}) | |
| 257 | + if sid1 != sid2 || cid1 != cid2 { | |
| 258 | + t.Fatalf("deterministic mapping failed: %q != %q", sid1, sid2) | |
| 259 | + } | |
| 260 | + if _, err := uuid.Parse(sid1); err != nil { | |
| 261 | + t.Fatalf("mapped sid is not a valid UUID: %q", sid1) | |
| 262 | + } | |
| 263 | + | |
| 264 | + // 3. Fallback to ctx session | |
| 265 | + ctx := util.WithSessionID(context.Background(), "ctx-session-abc") | |
| 266 | + sidCtx, cidCtx := resolveDevinSessionAndCascadeIDs(ctx, "", "", cliproxyexecutor.Options{}) | |
| 267 | + if _, err := uuid.Parse(sidCtx); err != nil { | |
| 268 | + t.Fatalf("sidCtx is not a valid UUID: %q", sidCtx) | |
| 269 | + } | |
| 270 | + if sidCtx != cidCtx { | |
| 271 | + t.Fatalf("sidCtx %q != cidCtx %q", sidCtx, cidCtx) | |
| 272 | + } | |
| 273 | + | |
| 274 | + // 4. Fallback to fresh UUID when nothing supplied | |
| 275 | + sidEmpty, cidEmpty := resolveDevinSessionAndCascadeIDs(context.Background(), "", "", cliproxyexecutor.Options{}) | |
| 276 | + if _, err := uuid.Parse(sidEmpty); err != nil { | |
| 277 | + t.Fatalf("sidEmpty is not a valid UUID: %q", sidEmpty) | |
| 278 | + } | |
| 279 | + if sidEmpty != cidEmpty { | |
| 280 | + t.Fatalf("sidEmpty %q != cidEmpty %q", sidEmpty, cidEmpty) | |
| 281 | + } | |
| 282 | +} | |
| 283 | + | |
| 243 | 284 | func TestConsumeDevinFramesToInteractions(t *testing.T) { |
| 244 | 285 | // Synthesize a Connect stream with 2 data frames and 1 EOS trailer |
| 245 | 286 | var streamBuf bytes.Buffer |
| @@ -406,6 +406,9 @@ func BuildDevinGetChatMessageRequest( · 405 unmodified lines | ||
| 406 | 406 | f15Bytes = protowire.AppendTag(f15Bytes, 1, protowire.BytesType) |
| 407 | 407 | f15Bytes = protowire.AppendString(f15Bytes, sessionID) |
| 408 | 408 | |
| 409 | + f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType) | |
| 410 | + f15Bytes = protowire.AppendVarint(f15Bytes, 53) | |
| 411 | + | |
| 409 | 412 | f15Bytes = protowire.AppendTag(f15Bytes, 3, protowire.VarintType) |
| 410 | 413 | f15Bytes = protowire.AppendVarint(f15Bytes, 4) |
| 411 | 414 | |