Skip to content

Commit f681dd0

Browse files
committed
fix(imports): align picker and child contracts
1 parent 24b233e commit f681dd0

8 files changed

Lines changed: 237 additions & 31 deletions

File tree

‎internal/agentsessions/registry.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,30 @@ func ParseImportTag(tag string) (agent string, sourceID string, ok bool) {
121121
return sessions.ParseImportedSessionTag(tag)
122122
}
123123

124+
// ForeignSourceRefFromTag returns the display-level "agent:id" identity used
125+
// to suppress a foreign picker row after it has been imported. Unlike
126+
// ParseImportTag, this deliberately recognizes the legacy
127+
// "imported:<agent>:<plaintext id>" spelling written by older builds. It is
128+
// not an authority check: callers deciding whether a session may inherit
129+
// foreign state must continue to use the strict versioned parser.
130+
func ForeignSourceRefFromTag(tag string) (string, bool) {
131+
if agent, sourceID, ok := ParseImportTag(tag); ok {
132+
return agent + ":" + sourceID, true
133+
}
134+
trimmed := strings.TrimSpace(tag)
135+
rest := strings.TrimPrefix(trimmed, importTagPrefix)
136+
if rest == trimmed || strings.HasPrefix(rest, "v1:") {
137+
return "", false
138+
}
139+
agent, sourceID, found := strings.Cut(rest, ":")
140+
agent = strings.TrimSpace(agent)
141+
sourceID = strings.TrimSpace(sourceID)
142+
if !found || agent == "" || sourceID == "" {
143+
return "", false
144+
}
145+
return agent + ":" + sourceID, true
146+
}
147+
124148
// ImportedAgent is the agent a session was imported from, or "" for a session
125149
// Zero produced itself. Unlike ParseImportTag this accepts the older
126150
// "imported:<agent>" form, so sessions imported before the tag carried a source

‎internal/sessions/import_provenance.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ func IsImportedSession(metadata Metadata) bool {
4343
_, _, ok := ParseImportedSessionTag(metadata.Tag)
4444
return ok
4545
}
46+
47+
// resolveImportedModelFields applies the imported-session model policy shared
48+
// by every lineage operation. Older imports stored the foreign model in the
49+
// operational ModelID field. Unless Zero has explicitly selected that model,
50+
// descendants retain it as provenance only.
51+
func resolveImportedModelFields(parent Metadata) (modelID, sourceModelID string) {
52+
modelID = parent.ModelID
53+
sourceModelID = parent.SourceModelID
54+
if IsImportedSession(parent) && sourceModelID == "" && !parent.ModelSelectedLocally {
55+
sourceModelID = modelID
56+
modelID = ""
57+
}
58+
return modelID, sourceModelID
59+
}

‎internal/sessions/lineage.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ func (store *Store) CreateChild(parentSessionID string, input ChildInput) (Metad
2525
if len(parentEvents) > 0 {
2626
lastParentEvent = parentEvents[len(parentEvents)-1]
2727
}
28+
parentModelID, sourceModelID := resolveImportedModelFields(*parent)
2829

2930
child, err := store.Create(CreateInput{
3031
SessionID: input.SessionID,
3132
SessionKind: SessionKindChild,
3233
Title: childTitle(input.Title, input.AgentName, parent.Title),
3334
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
3435
WorkspaceKey: derivedWorkspaceKey(input.Cwd, parent.WorkspaceKey),
35-
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
36+
ModelID: firstNonEmpty(input.ModelID, parentModelID),
37+
SourceModelID: sourceModelID,
3638
Provider: firstNonEmpty(input.Provider, parent.Provider),
3739
Tag: input.Tag,
3840
Depth: input.Depth,

‎internal/sessions/store.go‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -534,15 +534,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
534534
if kind != SessionKindFork && kind != SessionKindSide {
535535
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
536536
}
537-
parentModelID := parent.ModelID
538-
sourceModelID := parent.SourceModelID
539-
if IsImportedSession(*parent) && sourceModelID == "" && !parent.ModelSelectedLocally {
540-
// Older imports stored the foreign model in the operational field. A new
541-
// fork must migrate that value to provenance instead of inheriting it as a
542-
// local provider choice.
543-
sourceModelID = parentModelID
544-
parentModelID = ""
545-
}
537+
parentModelID, sourceModelID := resolveImportedModelFields(*parent)
546538
fork, err := store.Create(CreateInput{
547539
SessionID: input.SessionID,
548540
SessionKind: kind,

‎internal/sessions/store_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,70 @@ func TestNativeImportedPrefixTagRetainsOperationalModelOnFork(t *testing.T) {
201201
}
202202
}
203203

204+
func TestCreateChildMigratesImportedParentModelProvenance(t *testing.T) {
205+
for _, tc := range []struct {
206+
name string
207+
parentModel string
208+
parentSourceModel string
209+
selectLocalModel string
210+
childModel string
211+
wantModel string
212+
wantSourceModel string
213+
}{
214+
{
215+
name: "legacy foreign model is provenance only",
216+
parentModel: "foreign-model",
217+
wantSourceModel: "foreign-model",
218+
},
219+
{
220+
name: "explicit child model overrides migrated parent",
221+
parentModel: "foreign-model",
222+
childModel: "specialist-model",
223+
wantModel: "specialist-model",
224+
wantSourceModel: "foreign-model",
225+
},
226+
{
227+
name: "locally selected parent model remains operational",
228+
parentSourceModel: "foreign-model",
229+
selectLocalModel: "zero-local-model",
230+
wantModel: "zero-local-model",
231+
wantSourceModel: "foreign-model",
232+
},
233+
} {
234+
t.Run(tc.name, func(t *testing.T) {
235+
store := NewStore(StoreOptions{RootDir: t.TempDir()})
236+
parent, err := store.Create(CreateInput{
237+
SessionID: "imported-parent",
238+
Tag: ImportedSessionTag("claude-code", "foreign-id"),
239+
ModelID: tc.parentModel,
240+
SourceModelID: tc.parentSourceModel,
241+
})
242+
if err != nil {
243+
t.Fatal(err)
244+
}
245+
if tc.selectLocalModel != "" {
246+
parent, err = store.UpdateModel(parent.SessionID, tc.selectLocalModel)
247+
if err != nil {
248+
t.Fatal(err)
249+
}
250+
if !parent.ModelSelectedLocally {
251+
t.Fatal("imported parent did not retain the local-selection authority bit")
252+
}
253+
}
254+
child, err := store.CreateChild(parent.SessionID, ChildInput{
255+
SessionID: "child",
256+
ModelID: tc.childModel,
257+
})
258+
if err != nil {
259+
t.Fatal(err)
260+
}
261+
if child.ModelID != tc.wantModel || child.SourceModelID != tc.wantSourceModel {
262+
t.Fatalf("child model fields = (%q, %q), want (%q, %q)", child.ModelID, child.SourceModelID, tc.wantModel, tc.wantSourceModel)
263+
}
264+
})
265+
}
266+
}
267+
204268
func TestStoreForkSupportsNonResumableSideSession(t *testing.T) {
205269
store := NewStore(StoreOptions{RootDir: t.TempDir()})
206270
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})

‎internal/tui/session.go‎

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,8 @@ func (m model) sessionPickerCmd() (model, tea.Cmd) {
256256
now: m.now,
257257
}
258258
return m, func() tea.Msg {
259-
picker, localErr := snapshot.buildSessionPicker()
260-
warning := ""
261-
if localErr != nil {
262-
warning = "Sessions\nWarning: could not read local Zero sessions; showing external sessions only: " + agentsessions.DisplayField(localErr.Error())
263-
}
259+
picker, foreignProblems, localErr := snapshot.buildSessionPicker()
260+
warning := sessionPickerWarning(localErr, foreignProblems)
264261
msg := sessionPickerLoadedMsg{originSession: originSession, generation: generation}
265262
if picker != nil {
266263
msg.picker, msg.text = picker, warning
@@ -275,6 +272,28 @@ func (m model) sessionPickerCmd() (model, tea.Cmd) {
275272
}
276273
}
277274

275+
// sessionPickerWarning keeps partial discovery failures visible without
276+
// turning them into a failed picker. Errors may contain paths or transcript
277+
// identifiers from foreign stores, so this is also the single display
278+
// boundary that sanitizes every local and foreign problem before it reaches
279+
// the terminal transcript.
280+
func sessionPickerWarning(localErr error, foreignProblems []error) string {
281+
warnings := []string{}
282+
if localErr != nil {
283+
warnings = append(warnings, "Warning: could not read local Zero sessions; showing external sessions only: "+agentsessions.DisplayField(localErr.Error()))
284+
}
285+
for _, problem := range foreignProblems {
286+
if problem == nil {
287+
continue
288+
}
289+
warnings = append(warnings, "Warning: could not read some external sessions: "+agentsessions.DisplayField(problem.Error()))
290+
}
291+
if len(warnings) == 0 {
292+
return ""
293+
}
294+
return "Sessions\n" + strings.Join(warnings, "\n")
295+
}
296+
278297
// sessionPickerResultIsCurrent reports whether a discovery result still
279298
// describes a request whose preconditions hold: no run has started, the same
280299
// session is active, and no newer /resume has replaced it.
@@ -495,16 +514,17 @@ func sessionWhenTime(parsed time.Time, now time.Time) string {
495514
// one row per resumable session — title (Label) + id and relative age (Meta). Returns
496515
// nil when there are no resumable sessions so the caller falls back to the text path.
497516
func (m model) newSessionPicker() *commandPicker {
498-
picker, _ := m.buildSessionPicker()
517+
picker, _, _ := m.buildSessionPicker()
499518
return picker
500519
}
501520

502521
// buildSessionPicker keeps the local and external sources independent. A local
503-
// store failure removes only local rows; callers receive the error separately
504-
// so they can warn without hiding discoverable external work.
505-
func (m model) buildSessionPicker() (*commandPicker, error) {
522+
// store failure removes only local rows; callers receive local and foreign
523+
// problems separately so they can warn without hiding discoverable work from
524+
// the sources that did succeed.
525+
func (m model) buildSessionPicker() (*commandPicker, []error, error) {
506526
if m.sessionStore == nil {
507-
return nil, nil
527+
return nil, nil, nil
508528
}
509529
// A FAILED READ IS THE ONLY REASON TO GIVE UP HERE. An EMPTY local history is
510530
// not: foreign sessions are discovered independently of the store, and the
@@ -555,7 +575,8 @@ func (m model) buildSessionPicker() (*commandPicker, error) {
555575
Tab: agent,
556576
})
557577
}
558-
return pickerFromParts(items, m.foreignSessionItems(metas, now)), err
578+
foreign, problems := m.foreignSessionItems(metas, now)
579+
return pickerFromParts(items, foreign), problems, err
559580
}
560581

561582
// pickerFromParts assembles the picker from the two independent sources, and
@@ -674,8 +695,9 @@ func importedSessionNote(result agentsessions.ImportResult, workspace string) st
674695
//
675696
// Reading these is a bounded index of each transcript's head, never the whole
676697
// file (see internal/agentsessions). A store that is missing or has changed
677-
// shape contributes nothing rather than failing the picker — /resume must still
678-
// open on a machine where one vendor shipped a new format this morning.
698+
// shape contributes a caller-visible problem rather than failing the picker —
699+
// /resume must still open on a machine where one vendor shipped a new format
700+
// this morning, without pretending the partial index is complete.
679701
// importedSourceRefs is the set of foreign sessions that have actually been
680702
// imported, so the picker can skip offering them a second time — listing a
681703
// session twice, once as itself and once as its copy, is worse than not offering
@@ -700,17 +722,17 @@ func importedSourceRefs(existing []sessions.Metadata) map[string]bool {
700722
if meta.EventCount == 0 {
701723
continue
702724
}
703-
if agent, sourceID, ok := agentsessions.ParseImportTag(meta.Tag); ok {
704-
imported[agent+":"+sourceID] = true
725+
if ref, ok := agentsessions.ForeignSourceRefFromTag(meta.Tag); ok {
726+
imported[ref] = true
705727
}
706728
}
707729
return imported
708730
}
709731

710-
func (m model) foreignSessionItems(existing []sessions.Metadata, now time.Time) []pickerItem {
732+
func (m model) foreignSessionItems(existing []sessions.Metadata, now time.Time) ([]pickerItem, []error) {
711733
imported := importedSourceRefs(existing)
712734

713-
found, _ := agentsessions.DiscoverAllCached(m.agentSessionsEnv, m.cwd)
735+
found, problems := agentsessions.DiscoverAllCached(m.agentSessionsEnv, m.cwd)
714736
items := make([]pickerItem, 0, len(found))
715737
for _, session := range found {
716738
ref := session.Agent + ":" + session.ID
@@ -736,7 +758,7 @@ func (m model) foreignSessionItems(existing []sessions.Metadata, now time.Time)
736758
ForeignSource: &source,
737759
})
738760
}
739-
return items
761+
return items, problems
740762
}
741763

742764
// sessionAgentName is the agent a session came from, for the picker's tab strip.

‎internal/tui/session_import_note_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func TestForeignResumeCanRetryAnEmptyTranslationWithoutCreatingSessions(t *testi
7575
}
7676

7777
agentsessions.InvalidateDiscovery()
78-
items := m.foreignSessionItems(nil, time.Now())
78+
items, _ := m.foreignSessionItems(nil, time.Now())
7979
if len(items) != 1 || items[0].Value != "claude-code:empty" {
8080
t.Fatalf("retryable foreign source disappeared from the picker: %+v", items)
8181
}

0 commit comments

Comments
 (0)