Skip to content

Commit 15272a5

Browse files
committed
fix(acp): resolve effective conversation cwd
1 parent 8313526 commit 15272a5

14 files changed

Lines changed: 436 additions & 30 deletions

api/acp_bootstrap.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
type acpBootstrapResponse struct {
2222
Conversation *spritzv1.SpritzConversation `json:"conversation"`
2323
EffectiveSessionID string `json:"effectiveSessionId,omitempty"`
24+
EffectiveCWD string `json:"effectiveCwd,omitempty"`
2425
BindingState string `json:"bindingState,omitempty"`
2526
Loaded bool `json:"loaded,omitempty"`
2627
Replaced bool `json:"replaced,omitempty"`
@@ -417,10 +418,16 @@ func (s *server) bootstrapACPConversationBinding(ctx context.Context, conversati
417418
return nil, err
418419
}
419420

420-
return s.bootstrapACPConversationBindingWithClient(ctx, conversation, client, initResult)
421+
return s.bootstrapACPConversationBindingWithClient(ctx, conversation, spritz, client, initResult)
421422
}
422423

423-
func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context, conversation *spritzv1.SpritzConversation, client *acpBootstrapInstanceClient, initResult *acpBootstrapInitializeResult) (*acpBootstrapResponse, error) {
424+
func (s *server) bootstrapACPConversationBindingWithClient(
425+
ctx context.Context,
426+
conversation *spritzv1.SpritzConversation,
427+
spritz *spritzv1.Spritz,
428+
client *acpBootstrapInstanceClient,
429+
initResult *acpBootstrapInitializeResult,
430+
) (*acpBootstrapResponse, error) {
424431
if !initResult.AgentCapabilities.LoadSession {
425432
err := errors.New("agent does not support session/load")
426433
s.recordConversationBindingError(ctx, conversation.Namespace, conversation.Name, "", err)
@@ -429,6 +436,7 @@ func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context,
429436

430437
agentInfo := normalizeBootstrapAgentInfo(initResult)
431438
capabilities := normalizeBootstrapCapabilities(initResult)
439+
effectiveCWD := resolveConversationEffectiveCWD(spritz, conversation)
432440
effectiveSessionID := strings.TrimSpace(conversation.Spec.SessionID)
433441
previousSessionID := ""
434442
bindingState := "active"
@@ -438,12 +446,12 @@ func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context,
438446
var err error
439447

440448
if effectiveSessionID != "" {
441-
replayMessageCount, err = client.loadSession(ctx, effectiveSessionID, normalizeConversationCWD(conversation.Spec.CWD))
449+
replayMessageCount, err = client.loadSession(ctx, effectiveSessionID, effectiveCWD)
442450
if err != nil {
443451
var rpcErr *acpBootstrapRPCError
444452
if errors.As(err, &rpcErr) && rpcErr.missingSession() {
445453
previousSessionID = effectiveSessionID
446-
effectiveSessionID, err = client.newSession(ctx, normalizeConversationCWD(conversation.Spec.CWD))
454+
effectiveSessionID, err = client.newSession(ctx, effectiveCWD)
447455
if err != nil {
448456
s.recordConversationBindingError(ctx, conversation.Namespace, conversation.Name, previousSessionID, err)
449457
return nil, err
@@ -458,7 +466,7 @@ func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context,
458466
loaded = true
459467
}
460468
} else {
461-
effectiveSessionID, err = client.newSession(ctx, normalizeConversationCWD(conversation.Spec.CWD))
469+
effectiveSessionID, err = client.newSession(ctx, effectiveCWD)
462470
if err != nil {
463471
s.recordConversationBindingError(ctx, conversation.Namespace, conversation.Name, "", err)
464472
return nil, err
@@ -473,11 +481,13 @@ func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context,
473481

474482
updatedConversation, err := s.updateConversationBinding(ctx, conversation.Namespace, conversation.Name, func(current *spritzv1.SpritzConversation) {
475483
now := metav1.Now()
484+
current.Spec.CWD = normalizeConversationOverrideCWD(spritz, current.Spec.CWD)
476485
current.Spec.SessionID = effectiveSessionID
477486
current.Spec.AgentInfo = agentInfo
478487
current.Spec.Capabilities = capabilities
479488
current.Status.BoundSessionID = effectiveSessionID
480489
current.Status.BindingState = bindingState
490+
current.Status.EffectiveCWD = effectiveCWD
481491
current.Status.PreviousSessionID = previousSessionID
482492
current.Status.LastBoundAt = &now
483493
current.Status.LastReplayMessageCount = replayMessageCount
@@ -496,6 +506,7 @@ func (s *server) bootstrapACPConversationBindingWithClient(ctx context.Context,
496506
return &acpBootstrapResponse{
497507
Conversation: updatedConversation,
498508
EffectiveSessionID: effectiveSessionID,
509+
EffectiveCWD: effectiveCWD,
499510
BindingState: bindingState,
500511
Loaded: loaded,
501512
Replaced: replaced,

api/acp_cwd.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package main
2+
3+
import (
4+
"net/url"
5+
"path"
6+
"strconv"
7+
"strings"
8+
9+
spritzv1 "spritz.sh/operator/api/v1"
10+
)
11+
12+
var legacyInheritedConversationCWDs = map[string]struct{}{
13+
defaultACPCWD: {},
14+
"/workspace": {},
15+
}
16+
17+
// normalizeConversationCWD trims client input and preserves empty values so the
18+
// conversation resource can distinguish "no override" from an explicit cwd.
19+
func normalizeConversationCWD(value string) string {
20+
return strings.TrimSpace(value)
21+
}
22+
23+
// resolveConversationEffectiveCWD resolves the cwd that should be used for ACP
24+
// bootstrap and reconnect flows after accounting for explicit overrides,
25+
// instance defaults, and legacy copied-default values.
26+
func resolveConversationEffectiveCWD(spritz *spritzv1.Spritz, conversation *spritzv1.SpritzConversation) string {
27+
defaultCWD := resolveSpritzDefaultCWD(spritz)
28+
if conversation == nil {
29+
return defaultCWD
30+
}
31+
if override := normalizeConversationOverrideCWD(spritz, conversation.Spec.CWD); override != "" {
32+
return override
33+
}
34+
return defaultCWD
35+
}
36+
37+
// normalizeConversationOverrideCWD clears legacy copied defaults so bootstrap
38+
// can distinguish a real override from inherited instance state.
39+
func normalizeConversationOverrideCWD(spritz *spritzv1.Spritz, value string) string {
40+
override := normalizeConversationCWD(value)
41+
if override == "" {
42+
return ""
43+
}
44+
45+
defaultCWD := resolveSpritzDefaultCWD(spritz)
46+
if override == defaultCWD {
47+
return ""
48+
}
49+
if _, ok := legacyInheritedConversationCWDs[override]; ok && override != defaultCWD {
50+
return ""
51+
}
52+
return override
53+
}
54+
55+
// resolveSpritzDefaultCWD derives the runtime-owned default cwd from explicit
56+
// env overrides first and falls back to the primary repo checkout directory.
57+
func resolveSpritzDefaultCWD(spritz *spritzv1.Spritz) string {
58+
if spritz == nil {
59+
return defaultACPCWD
60+
}
61+
62+
for _, key := range []string{
63+
"SPRITZ_CONVERSATION_DEFAULT_CWD",
64+
"SPRITZ_CODEX_WORKDIR",
65+
"SPRITZ_CLAUDE_CODE_WORKDIR",
66+
"SPRITZ_REPO_DIR",
67+
} {
68+
if value := spritzEnvValue(spritz, key); value != "" {
69+
return value
70+
}
71+
}
72+
73+
if repoDir := resolvePrimaryRepoDir(spritz); repoDir != "" {
74+
return repoDir
75+
}
76+
return defaultACPCWD
77+
}
78+
79+
func spritzEnvValue(spritz *spritzv1.Spritz, key string) string {
80+
if spritz == nil {
81+
return ""
82+
}
83+
for i := len(spritz.Spec.Env) - 1; i >= 0; i-- {
84+
env := spritz.Spec.Env[i]
85+
if strings.TrimSpace(env.Name) != key {
86+
continue
87+
}
88+
if value := strings.TrimSpace(env.Value); value != "" {
89+
return value
90+
}
91+
}
92+
return ""
93+
}
94+
95+
func resolvePrimaryRepoDir(spritz *spritzv1.Spritz) string {
96+
if spritz == nil {
97+
return ""
98+
}
99+
100+
repos := spritz.Spec.Repos
101+
if len(repos) > 0 {
102+
return repoDirForConversationDefault(repos[0], 0, len(repos))
103+
}
104+
if spritz.Spec.Repo != nil && strings.TrimSpace(spritz.Spec.Repo.URL) != "" {
105+
return repoDirForConversationDefault(*spritz.Spec.Repo, 0, 1)
106+
}
107+
return ""
108+
}
109+
110+
func repoDirForConversationDefault(repo spritzv1.SpritzRepo, index int, total int) string {
111+
repoDir := strings.TrimSpace(repo.Dir)
112+
if repoDir == "" {
113+
if total > 1 {
114+
repoDir = "/workspace/repo-" + strconv.Itoa(index+1)
115+
} else if inferred := inferConversationRepoName(repo.URL); inferred != "" {
116+
repoDir = path.Join("/workspace", inferred)
117+
} else {
118+
repoDir = "/workspace/repo"
119+
}
120+
}
121+
if !strings.HasPrefix(repoDir, "/") {
122+
repoDir = path.Join("/workspace", repoDir)
123+
}
124+
return path.Clean(repoDir)
125+
}
126+
127+
func inferConversationRepoName(raw string) string {
128+
value := strings.TrimSpace(raw)
129+
if value == "" {
130+
return ""
131+
}
132+
pathPart := ""
133+
if strings.Contains(value, "://") {
134+
parsed, err := url.Parse(value)
135+
if err != nil {
136+
return ""
137+
}
138+
pathPart = parsed.Path
139+
} else if strings.Contains(value, ":") {
140+
parts := strings.SplitN(value, ":", 2)
141+
if len(parts) == 2 {
142+
pathPart = parts[1]
143+
} else {
144+
pathPart = value
145+
}
146+
} else {
147+
pathPart = value
148+
}
149+
pathPart = strings.SplitN(pathPart, "?", 2)[0]
150+
pathPart = strings.SplitN(pathPart, "#", 2)[0]
151+
pathPart = strings.TrimSuffix(pathPart, "/")
152+
if pathPart == "" {
153+
return ""
154+
}
155+
base := path.Base(pathPart)
156+
if base == "." || base == "/" {
157+
return ""
158+
}
159+
base = strings.TrimSuffix(base, ".git")
160+
if base == "" || base == "." || base == "/" {
161+
return ""
162+
}
163+
return base
164+
}

api/acp_helpers.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,6 @@ func normalizeConversationCapabilities(status *spritzv1.SpritzACPStatus) *spritz
7272
return capabilities
7373
}
7474

75-
func normalizeConversationCWD(value string) string {
76-
trimmed := strings.TrimSpace(value)
77-
if trimmed == "" {
78-
return defaultACPCWD
79-
}
80-
return trimmed
81-
}
82-
8375
func conversationDisplayTitle(conversation *spritzv1.SpritzConversation) string {
8476
if conversation == nil {
8577
return defaultACPConversationTitle

0 commit comments

Comments
 (0)