Skip to content

Commit 100ad8a

Browse files
committed
Merge branch 'fork-pr-337-1776410957' of https://github.com/phantom5099/neo-code into fork-pr-337-1776410957
2 parents 07f999c + 2b90cf9 commit 100ad8a

8 files changed

Lines changed: 232 additions & 25 deletions

File tree

internal/provider/openaicompat/chatcompletions/provider_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ func TestNewAndBuildRequest(t *testing.T) {
123123
if downgradedSchema["type"] != "object" {
124124
t.Fatalf("expected downgraded schema type object, got %+v", downgradedSchema["type"])
125125
}
126-
if downgradedSchema["x-neocode-schema-downgraded"] != true {
127-
t.Fatalf("expected downgrade marker, got %+v", downgradedSchema)
126+
if _, ok := downgradedSchema["x-neocode-schema-downgraded"]; ok {
127+
t.Fatalf("expected no custom downgrade marker in outbound schema, got %+v", downgradedSchema)
128128
}
129129

130130
withSessionAsset, err := BuildRequest(context.Background(), testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{

internal/provider/openaicompat/chatcompletions/request.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,10 @@ func normalizeToolSchemaForOpenAI(schema map[string]any) map[string]any {
9191
typeName, _ := normalized["type"].(string)
9292
if strings.TrimSpace(strings.ToLower(typeName)) != "object" {
9393
normalized["type"] = "object"
94-
normalized["x-neocode-schema-downgraded"] = true
9594
}
9695

9796
if _, ok := normalized["properties"].(map[string]any); !ok {
9897
normalized["properties"] = map[string]any{}
99-
if strings.TrimSpace(strings.ToLower(typeName)) != "object" {
100-
normalized["x-neocode-schema-downgraded"] = true
101-
}
10298
}
10399
return normalized
104100
}

internal/session/asset_store_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,31 @@ func TestJSONStoreOpenAndStatMissingStoredFiles(t *testing.T) {
239239
}
240240
}
241241

242+
func TestJSONStoreDeleteAsset(t *testing.T) {
243+
t.Parallel()
244+
245+
store := NewJSONStore(t.TempDir(), t.TempDir())
246+
sessionID := "session-delete-asset"
247+
meta, err := store.SaveAsset(context.Background(), sessionID, strings.NewReader("img"), "image/png")
248+
if err != nil {
249+
t.Fatalf("save seed asset: %v", err)
250+
}
251+
252+
if err := store.DeleteAsset(context.Background(), sessionID, meta.ID); err != nil {
253+
t.Fatalf("DeleteAsset() error = %v", err)
254+
}
255+
if _, statErr := os.Stat(store.assetPath(sessionID, meta.ID)); !errors.Is(statErr, os.ErrNotExist) {
256+
t.Fatalf("expected removed asset file, got %v", statErr)
257+
}
258+
if _, statErr := os.Stat(store.assetMetaPath(sessionID, meta.ID)); !errors.Is(statErr, os.ErrNotExist) {
259+
t.Fatalf("expected removed asset meta file, got %v", statErr)
260+
}
261+
262+
if err := store.DeleteAsset(context.Background(), sessionID, meta.ID); err != nil {
263+
t.Fatalf("DeleteAsset() should ignore already deleted files, got %v", err)
264+
}
265+
}
266+
242267
type failingReader struct{}
243268

244269
func (failingReader) Read(_ []byte) (int, error) {

internal/session/input_preparer.go

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ type InputPreparer struct {
7070
assetStore AssetStore
7171
}
7272

73+
type assetCleanupStore interface {
74+
DeleteAsset(ctx context.Context, sessionID string, assetID string) error
75+
}
76+
7377
// NewInputPreparer 创建会话输入归一化组件。
7478
func NewInputPreparer(store Store, assetStore AssetStore) *InputPreparer {
7579
return &InputPreparer{
@@ -96,7 +100,7 @@ func (p *InputPreparer) Prepare(ctx context.Context, input PrepareInput) (Prepar
96100
}
97101

98102
sessionTitle := buildSessionTitle(trimmedText, len(input.Images) > 0)
99-
session, sessionCreated, err := p.loadOrCreateSession(
103+
session, sessionCreated, pendingUpdate, err := p.loadOrCreateSession(
100104
ctx,
101105
input.SessionID,
102106
sessionTitle,
@@ -117,6 +121,7 @@ func (p *InputPreparer) Prepare(ctx context.Context, input PrepareInput) (Prepar
117121
path := strings.TrimSpace(image.Path)
118122
if path == "" {
119123
p.rollbackCreatedSession(ctx, session.ID, sessionCreated)
124+
p.cleanupSavedAssets(ctx, session.ID, savedAssets)
120125
return PreparedInput{}, &AssetSaveError{
121126
SessionID: session.ID,
122127
Index: index,
@@ -129,6 +134,7 @@ func (p *InputPreparer) Prepare(ctx context.Context, input PrepareInput) (Prepar
129134
meta, err := p.saveImageAsset(ctx, session.ID, session.Workdir, path, mimeType)
130135
if err != nil {
131136
p.rollbackCreatedSession(ctx, session.ID, sessionCreated)
137+
p.cleanupSavedAssets(ctx, session.ID, savedAssets)
132138
return PreparedInput{}, &AssetSaveError{
133139
SessionID: session.ID,
134140
Index: index,
@@ -142,8 +148,14 @@ func (p *InputPreparer) Prepare(ctx context.Context, input PrepareInput) (Prepar
142148

143149
if err := providertypes.ValidateParts(parts); err != nil {
144150
p.rollbackCreatedSession(ctx, session.ID, sessionCreated)
151+
p.cleanupSavedAssets(ctx, session.ID, savedAssets)
145152
return PreparedInput{}, fmt.Errorf("session: normalize parts: %w", err)
146153
}
154+
if err := p.persistSessionWorkdirUpdate(ctx, pendingUpdate); err != nil {
155+
p.rollbackCreatedSession(ctx, session.ID, sessionCreated)
156+
p.cleanupSavedAssets(ctx, session.ID, savedAssets)
157+
return PreparedInput{}, err
158+
}
147159

148160
return PreparedInput{
149161
SessionID: session.ID,
@@ -302,47 +314,53 @@ func resolveImagePath(workdir string, path string) (string, error) {
302314
return resolved, nil
303315
}
304316

317+
// sessionWorkdirUpdate 描述已有会话 workdir 的待提交变更,确保 Prepare 成功后再落盘。
318+
type sessionWorkdirUpdate struct {
319+
session Session
320+
dirty bool
321+
}
322+
305323
func (p *InputPreparer) loadOrCreateSession(
306324
ctx context.Context,
307325
sessionID string,
308326
title string,
309327
defaultWorkdir string,
310328
requestedWorkdir string,
311-
) (Session, bool, error) {
329+
) (Session, bool, sessionWorkdirUpdate, error) {
312330
if strings.TrimSpace(sessionID) == "" {
313331
sessionWorkdir, err := resolveWorkdirForInput(defaultWorkdir, "", requestedWorkdir)
314332
if err != nil {
315-
return Session{}, false, err
333+
return Session{}, false, sessionWorkdirUpdate{}, err
316334
}
317335
session := NewWithWorkdir(title, sessionWorkdir)
318336
if err := p.store.Save(ctx, &session); err != nil {
319-
return Session{}, false, err
337+
return Session{}, false, sessionWorkdirUpdate{}, err
320338
}
321-
return session, true, nil
339+
return session, true, sessionWorkdirUpdate{}, nil
322340
}
323341

324342
session, err := p.store.Load(ctx, sessionID)
325343
if err != nil {
326-
return Session{}, false, err
344+
return Session{}, false, sessionWorkdirUpdate{}, err
327345
}
328346
if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" {
329-
return session, false, nil
347+
return session, false, sessionWorkdirUpdate{}, nil
330348
}
331349

332350
resolved, err := resolveWorkdirForInput(defaultWorkdir, session.Workdir, requestedWorkdir)
333351
if err != nil {
334-
return Session{}, false, err
352+
return Session{}, false, sessionWorkdirUpdate{}, err
335353
}
336354
if session.Workdir == resolved {
337-
return session, false, nil
355+
return session, false, sessionWorkdirUpdate{}, nil
338356
}
339357

340358
session.Workdir = resolved
341359
session.UpdatedAt = time.Now()
342-
if err := p.store.Save(ctx, &session); err != nil {
343-
return Session{}, false, err
344-
}
345-
return session, false, nil
360+
return session, false, sessionWorkdirUpdate{
361+
session: session,
362+
dirty: true,
363+
}, nil
346364
}
347365

348366
// rollbackCreatedSession 在本次 Prepare 新建会话后发生错误时回滚会话目录,避免残留孤儿会话。
@@ -356,6 +374,34 @@ func (p *InputPreparer) rollbackCreatedSession(ctx context.Context, sessionID st
356374
_ = p.store.DeleteSession(ctx, sessionID)
357375
}
358376

377+
// persistSessionWorkdirUpdate 在 Prepare 其余步骤完成后统一提交会话 workdir 更新,避免失败时出现部分提交。
378+
func (p *InputPreparer) persistSessionWorkdirUpdate(ctx context.Context, pending sessionWorkdirUpdate) error {
379+
if !pending.dirty {
380+
return nil
381+
}
382+
if err := p.store.Save(ctx, &pending.session); err != nil {
383+
return err
384+
}
385+
return nil
386+
}
387+
388+
// cleanupSavedAssets 在 Prepare 失败时尽力回收已落盘的附件,减少 existing session 残留垃圾文件。
389+
func (p *InputPreparer) cleanupSavedAssets(ctx context.Context, sessionID string, assets []AssetMeta) {
390+
if len(assets) == 0 || ctx.Err() != nil {
391+
return
392+
}
393+
cleanupStore, ok := p.assetStore.(assetCleanupStore)
394+
if !ok {
395+
return
396+
}
397+
for _, asset := range assets {
398+
if strings.TrimSpace(asset.ID) == "" {
399+
continue
400+
}
401+
_ = cleanupStore.DeleteAsset(ctx, sessionID, asset.ID)
402+
}
403+
}
404+
359405
func resolveWorkdirForInput(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) {
360406
base := EffectiveWorkdir(currentWorkdir, defaultWorkdir)
361407
if strings.TrimSpace(requestedWorkdir) == "" {

internal/session/input_preparer_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,76 @@ func TestInputPreparerPrepareErrors(t *testing.T) {
237237
t.Fatalf("expected existing session to remain, load error = %v", loadErr)
238238
}
239239
})
240+
241+
t.Run("existing session cleanup removes previously saved assets on later failure", func(t *testing.T) {
242+
existing := NewWithWorkdir("existing-cleanup", workdir)
243+
if err := store.Save(context.Background(), &existing); err != nil {
244+
t.Fatalf("Save() error = %v", err)
245+
}
246+
247+
okImage := filepath.Join(workdir, "ok.png")
248+
if err := os.WriteFile(okImage, minimalPNGBytes(), 0o644); err != nil {
249+
t.Fatalf("write image: %v", err)
250+
}
251+
252+
preparer := NewInputPreparer(store, store)
253+
_, err := preparer.Prepare(context.Background(), PrepareInput{
254+
SessionID: existing.ID,
255+
Text: "cleanup",
256+
Images: []PrepareImageInput{
257+
{Path: okImage},
258+
{Path: "not-found.png", MimeType: "image/png"},
259+
},
260+
DefaultWorkdir: workdir,
261+
})
262+
if err == nil {
263+
t.Fatalf("expected prepare error")
264+
}
265+
266+
entries, readErr := os.ReadDir(store.assetsDir(existing.ID))
267+
if readErr != nil {
268+
t.Fatalf("ReadDir() error = %v", readErr)
269+
}
270+
if len(entries) != 0 {
271+
t.Fatalf("expected no leftover assets, got %d files", len(entries))
272+
}
273+
})
274+
275+
t.Run("existing session workdir change is not persisted when prepare fails", func(t *testing.T) {
276+
currentWorkdir := filepath.Join(workdir, "current")
277+
if err := os.MkdirAll(currentWorkdir, 0o755); err != nil {
278+
t.Fatalf("mkdir current workdir: %v", err)
279+
}
280+
targetWorkdir := filepath.Join(currentWorkdir, "nested")
281+
if err := os.MkdirAll(targetWorkdir, 0o755); err != nil {
282+
t.Fatalf("mkdir nested workdir: %v", err)
283+
}
284+
285+
existing := NewWithWorkdir("existing-workdir", currentWorkdir)
286+
if err := store.Save(context.Background(), &existing); err != nil {
287+
t.Fatalf("Save() error = %v", err)
288+
}
289+
290+
preparer := NewInputPreparer(store, store)
291+
_, err := preparer.Prepare(context.Background(), PrepareInput{
292+
SessionID: existing.ID,
293+
Text: "will fail",
294+
RequestedWorkdir: "nested",
295+
Images: []PrepareImageInput{{Path: "not-found.png", MimeType: "image/png"}},
296+
DefaultWorkdir: workdir,
297+
})
298+
if err == nil {
299+
t.Fatalf("expected prepare error")
300+
}
301+
302+
loaded, loadErr := store.Load(context.Background(), existing.ID)
303+
if loadErr != nil {
304+
t.Fatalf("Load() error = %v", loadErr)
305+
}
306+
if loaded.Workdir != currentWorkdir {
307+
t.Fatalf("expected workdir to stay %q, got %q", currentWorkdir, loaded.Workdir)
308+
}
309+
})
240310
}
241311

242312
func TestInputPreparerPrepareImagePathAndMimeValidation(t *testing.T) {

internal/session/store.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,39 @@ func (s *JSONStore) Stat(ctx context.Context, sessionID string, assetID string)
444444
return s.statUnlocked(sessionID, assetID)
445445
}
446446

447+
// DeleteAsset 删除指定会话附件的二进制与元数据文件,用于输入归一化失败后的清理。
448+
func (s *JSONStore) DeleteAsset(ctx context.Context, sessionID string, assetID string) error {
449+
if err := ctx.Err(); err != nil {
450+
return err
451+
}
452+
if err := validateStorageID("session id", sessionID); err != nil {
453+
return fmt.Errorf("session: %w", err)
454+
}
455+
if err := validateStorageID("asset id", assetID); err != nil {
456+
return fmt.Errorf("session: %w", err)
457+
}
458+
459+
s.mu.Lock()
460+
defer s.mu.Unlock()
461+
462+
target := s.assetPath(sessionID, assetID)
463+
if err := ensurePathWithinBase(s.baseDir, target); err != nil {
464+
return fmt.Errorf("session: resolve asset file path: %w", err)
465+
}
466+
if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
467+
return fmt.Errorf("session: delete asset file: %w", err)
468+
}
469+
470+
metaTarget := s.assetMetaPath(sessionID, assetID)
471+
if err := ensurePathWithinBase(s.baseDir, metaTarget); err != nil {
472+
return fmt.Errorf("session: resolve asset meta file path: %w", err)
473+
}
474+
if err := os.Remove(metaTarget); err != nil && !errors.Is(err, os.ErrNotExist) {
475+
return fmt.Errorf("session: delete asset meta file: %w", err)
476+
}
477+
return nil
478+
}
479+
447480
// statUnlocked 在调用方已持有读锁时读取附件元数据,避免重复加锁导致死锁风险。
448481
func (s *JSONStore) statUnlocked(sessionID string, assetID string) (AssetMeta, error) {
449482
target := s.assetMetaPath(sessionID, assetID)

internal/tui/core/app/update.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -966,9 +966,6 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool {
966966
if !a.shouldHandleRuntimeEvent(event) {
967967
return false
968968
}
969-
if a.state.ActiveSessionID == "" {
970-
a.state.ActiveSessionID = event.SessionID
971-
}
972969
handler, ok := runtimeEventHandlerRegistry[event.Type]
973970
if !ok {
974971
return false
@@ -1051,6 +1048,9 @@ func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) boo
10511048
if runID != "" {
10521049
a.state.ActiveRunID = runID
10531050
}
1051+
if sessionID := strings.TrimSpace(event.SessionID); sessionID != "" {
1052+
a.state.ActiveSessionID = sessionID
1053+
}
10541054
a.state.StatusText = statusThinking
10551055
a.state.StreamingReply = false
10561056
a.state.CurrentTool = ""
@@ -1085,6 +1085,9 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool
10851085
}
10861086
mapped := tuiservices.MapRunContextPayload(event.RunID, event.SessionID, payload)
10871087
a.state.RunContext = mapped
1088+
if strings.TrimSpace(mapped.SessionID) != "" {
1089+
a.state.ActiveSessionID = strings.TrimSpace(mapped.SessionID)
1090+
}
10881091
if strings.TrimSpace(mapped.RunID) != "" {
10891092
a.state.ActiveRunID = mapped.RunID
10901093
}

0 commit comments

Comments
 (0)