Keep extension load errors in chat
- commit
- 999d53a7fdbafc2299968995b98547b811708cc2
- author
- patriceckhart · · 2026-07-26 19:23 UTC
- parent
- 2c9dbfa
- signature
- unsigned
- refs
- tag: v0.3.12
5 files · +101 -4
M docs/extensions.md +4 -2
| @@ -644,8 +644,10 @@ Useful while developing an extension: edit, save, `/reload-ext`, | |||
| 644 | 644 | done. Explicit `--ext` paths are remembered and reloaded alongside | |
| 645 | 645 | discovered extensions. The temporary reload status reports each load | |
| 646 | 646 | error in red, including its message, and dismisses itself after five | |
| 647 | - | seconds. Subprocess startup and handshake errors include the exact stderr | |
| 648 | - | log path for the failed extension. | |
| 647 | + | seconds. Extension failures from startup and `/reload-ext` remain in the | |
| 648 | + | scrolling chat until `/clear`, without being sent to the model or saved in | |
| 649 | + | the session transcript. Subprocess startup and handshake errors include the | |
| 650 | + | exact stderr log path for the failed extension. | |
| 649 | 651 | ||
| 650 | 652 | ### TypeScript / Python | |
| 651 | 653 | ||
| @@ -644,8 +644,10 @@ Useful while developing an extension: edit, save, `/reload-ext`, | |||||
| 644 | done. Explicit `--ext` paths are remembered and reloaded alongside | 644 | done. Explicit `--ext` paths are remembered and reloaded alongside | ||
| 645 | discovered extensions. The temporary reload status reports each load | 645 | discovered extensions. The temporary reload status reports each load | ||
| 646 | error in red, including its message, and dismisses itself after five | 646 | error in red, including its message, and dismisses itself after five | ||
| 647 | - | seconds. Subprocess startup and handshake errors include the exact stderr | 647 | + | seconds. Extension failures from startup and `/reload-ext` remain in the |
| 648 | - | log path for the failed extension. | 648 | + | scrolling chat until `/clear`, without being sent to the model or saved in |
| 649 | + | the session transcript. Subprocess startup and handshake errors include the | |||
| 650 | + | exact stderr log path for the failed extension. | |||
| 649 | 651 | ||||
| 650 | ### TypeScript / Python | 652 | ### TypeScript / Python | ||
| 651 | 653 | ||||
M packages/agent/cli.go +4 -2
| @@ -401,17 +401,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 401 | 401 | var iv *modes.Interactive | |
| 402 | 402 | extHooks := &interactiveExtHooks{ivPtr: &iv} | |
| 403 | 403 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 404 | + | var startupExtensionErrors []string | |
| 404 | 405 | // --ext paths first so they win against installed extensions of | |
| 405 | 406 | // the same name (loadOne's first-write-wins semantics). | |
| 406 | 407 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| 407 | - | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 408 | + | startupExtensionErrors = append(startupExtensionErrors, e.Error()) | |
| 408 | 409 | } | |
| 409 | 410 | // --no-ext skips the global + project-local discovery scan; | |
| 410 | 411 | // explicit --ext paths above are still honoured so you can run | |
| 411 | 412 | // "only this extension" with --no-ext --ext ./x. | |
| 412 | 413 | if !args.NoExt { | |
| 413 | 414 | for _, e := range extMgr.Discover(ctx) { | |
| 414 | - | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 415 | + | startupExtensionErrors = append(startupExtensionErrors, e.Error()) | |
| 415 | 416 | } | |
| 416 | 417 | } | |
| 417 | 418 | // Wait briefly for extensions to flush their initial register_tool | |
| @@ -1013,6 +1014,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1013 | 1014 | CWD: r.CWD, | |
| 1014 | 1015 | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 1015 | 1016 | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 1017 | + | StartupExtensionErrors: startupExtensionErrors, | |
| 1016 | 1018 | StartupSkillNames: startupSkillNames(startupSkills), | |
| 1017 | 1019 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 1018 | 1020 | ZotHome: ZotHome(), | |
| @@ -401,17 +401,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 401 | var iv *modes.Interactive | 401 | var iv *modes.Interactive | ||
| 402 | extHooks := &interactiveExtHooks{ivPtr: &iv} | 402 | extHooks := &interactiveExtHooks{ivPtr: &iv} | ||
| 403 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | 403 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | ||
| 404 | + | var startupExtensionErrors []string | |||
| 404 | // --ext paths first so they win against installed extensions of | 405 | // --ext paths first so they win against installed extensions of | ||
| 405 | // the same name (loadOne's first-write-wins semantics). | 406 | // the same name (loadOne's first-write-wins semantics). | ||
| 406 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | 407 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | ||
| 407 | - | fmt.Fprintln(os.Stderr, "extension load:", e) | 408 | + | startupExtensionErrors = append(startupExtensionErrors, e.Error()) |
| 408 | } | 409 | } | ||
| 409 | // --no-ext skips the global + project-local discovery scan; | 410 | // --no-ext skips the global + project-local discovery scan; | ||
| 410 | // explicit --ext paths above are still honoured so you can run | 411 | // explicit --ext paths above are still honoured so you can run | ||
| 411 | // "only this extension" with --no-ext --ext ./x. | 412 | // "only this extension" with --no-ext --ext ./x. | ||
| 412 | if !args.NoExt { | 413 | if !args.NoExt { | ||
| 413 | for _, e := range extMgr.Discover(ctx) { | 414 | for _, e := range extMgr.Discover(ctx) { | ||
| 414 | - | fmt.Fprintln(os.Stderr, "extension load:", e) | 415 | + | startupExtensionErrors = append(startupExtensionErrors, e.Error()) |
| 415 | } | 416 | } | ||
| 416 | } | 417 | } | ||
| 417 | // Wait briefly for extensions to flush their initial register_tool | 418 | // Wait briefly for extensions to flush their initial register_tool | ||
| @@ -1013,6 +1014,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1013 | CWD: r.CWD, | 1014 | CWD: r.CWD, | ||
| 1014 | StartupContextPaths: instructionContextPaths(r.ContextFiles), | 1015 | StartupContextPaths: instructionContextPaths(r.ContextFiles), | ||
| 1015 | StartupExtensionNames: startupExtensionNames(extMgr.All()), | 1016 | StartupExtensionNames: startupExtensionNames(extMgr.All()), | ||
| 1017 | + | StartupExtensionErrors: startupExtensionErrors, | |||
| 1016 | StartupSkillNames: startupSkillNames(startupSkills), | 1018 | StartupSkillNames: startupSkillNames(startupSkills), | ||
| 1017 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 1019 | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | ||
| 1018 | ZotHome: ZotHome(), | 1020 | ZotHome: ZotHome(), | ||
M packages/agent/modes/interactive.go +25 -0
| @@ -44,6 +44,10 @@ type InteractiveConfig struct { | |||
| 44 | 44 | StartupExtensionNames []string | |
| 45 | 45 | StartupSkillNames []string | |
| 46 | 46 | ||
| 47 | + | // StartupExtensionErrors are non-fatal extension load failures collected | |
| 48 | + | // before the TUI starts. They are display-only and never enter the transcript. | |
| 49 | + | StartupExtensionErrors []string | |
| 50 | + | ||
| 47 | 51 | // ShowInstructionsAtStartup controls whether startup resources are visible. | |
| 48 | 52 | // nil means the default, off. | |
| 49 | 53 | ShowInstructionsAtStartup *bool | |
| @@ -282,6 +286,7 @@ type chatCacheKey struct { | |||
| 282 | 286 | statusErr string | |
| 283 | 287 | help string | |
| 284 | 288 | extNotes string | |
| 289 | + | reloadErrors string | |
| 285 | 290 | updateAvailable bool | |
| 286 | 291 | updateCurrent string | |
| 287 | 292 | updateLatest string | |
| @@ -486,6 +491,10 @@ type Interactive struct { | |||
| 486 | 491 | // transcript) until cleared by /clear or another reset. | |
| 487 | 492 | extNotes []string | |
| 488 | 493 | ||
| 494 | + | // reloadErrors are host-only extension reload failures. They stay in | |
| 495 | + | // the scrolling chat until /clear without entering the agent transcript. | |
| 496 | + | reloadErrors []string | |
| 497 | + | ||
| 489 | 498 | // shellRunning is true while a !command is executing. It shares | |
| 490 | 499 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | |
| 491 | 500 | // escape can start while one is in flight. | |
| @@ -582,6 +591,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 582 | 591 | fileSuggest: newFileSuggester(), | |
| 583 | 592 | spin: newSpinner(cfg.Theme), | |
| 584 | 593 | inputHistoryIndex: -1, | |
| 594 | + | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 585 | 595 | } | |
| 586 | 596 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 587 | 597 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| @@ -906,6 +916,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 906 | 916 | statusErr: i.statusErr, | |
| 907 | 917 | help: strings.Join(i.helpBlock, "\n"), | |
| 908 | 918 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 919 | + | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 909 | 920 | updateAvailable: i.updateInfo.Available, | |
| 910 | 921 | updateCurrent: i.updateInfo.Current, | |
| 911 | 922 | updateLatest: i.updateInfo.Latest, | |
| @@ -1032,6 +1043,19 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1032 | 1043 | chat = append(chat, "") | |
| 1033 | 1044 | } | |
| 1034 | 1045 | ||
| 1046 | + | // Reload failures remain in the scrolling chat until /clear. They are | |
| 1047 | + | // host-only display state, not provider messages or persisted context. | |
| 1048 | + | // While the latest failure is still the temporary status error, hide its | |
| 1049 | + | // stored copy to avoid painting the same message twice. | |
| 1050 | + | reloadErrors := i.reloadErrors | |
| 1051 | + | if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr { | |
| 1052 | + | reloadErrors = reloadErrors[:n-1] | |
| 1053 | + | } | |
| 1054 | + | if len(reloadErrors) > 0 { | |
| 1055 | + | chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...) | |
| 1056 | + | chat = append(chat, "") | |
| 1057 | + | } | |
| 1058 | + | ||
| 1035 | 1059 | // Strip trailing blank rows so the chat content sits flush | |
| 1036 | 1060 | // against the new "blank above status bar" row added by the | |
| 1037 | 1061 | // bottom-region assembly. Build() ends every message with a | |
| @@ -4068,6 +4092,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4068 | 4092 | i.parkedTotal = 0 | |
| 4069 | 4093 | i.scrollOffset = 0 | |
| 4070 | 4094 | i.extNotes = nil | |
| 4095 | + | i.reloadErrors = nil | |
| 4071 | 4096 | i.view.InvalidateRenderCache() | |
| 4072 | 4097 | i.mu.Unlock() | |
| 4073 | 4098 | case "/help": | |
| @@ -44,6 +44,10 @@ type InteractiveConfig struct { | |||||
| 44 | StartupExtensionNames []string | 44 | StartupExtensionNames []string | ||
| 45 | StartupSkillNames []string | 45 | StartupSkillNames []string | ||
| 46 | 46 | ||||
| 47 | + | // StartupExtensionErrors are non-fatal extension load failures collected | |||
| 48 | + | // before the TUI starts. They are display-only and never enter the transcript. | |||
| 49 | + | StartupExtensionErrors []string | |||
| 50 | + | ||||
| 47 | // ShowInstructionsAtStartup controls whether startup resources are visible. | 51 | // ShowInstructionsAtStartup controls whether startup resources are visible. | ||
| 48 | // nil means the default, off. | 52 | // nil means the default, off. | ||
| 49 | ShowInstructionsAtStartup *bool | 53 | ShowInstructionsAtStartup *bool | ||
| @@ -282,6 +286,7 @@ type chatCacheKey struct { | |||||
| 282 | statusErr string | 286 | statusErr string | ||
| 283 | help string | 287 | help string | ||
| 284 | extNotes string | 288 | extNotes string | ||
| 289 | + | reloadErrors string | |||
| 285 | updateAvailable bool | 290 | updateAvailable bool | ||
| 286 | updateCurrent string | 291 | updateCurrent string | ||
| 287 | updateLatest string | 292 | updateLatest string | ||
| @@ -486,6 +491,10 @@ type Interactive struct { | |||||
| 486 | // transcript) until cleared by /clear or another reset. | 491 | // transcript) until cleared by /clear or another reset. | ||
| 487 | extNotes []string | 492 | extNotes []string | ||
| 488 | 493 | ||||
| 494 | + | // reloadErrors are host-only extension reload failures. They stay in | |||
| 495 | + | // the scrolling chat until /clear without entering the agent transcript. | |||
| 496 | + | reloadErrors []string | |||
| 497 | + | ||||
| 489 | // shellRunning is true while a !command is executing. It shares | 498 | // shellRunning is true while a !command is executing. It shares | ||
| 490 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | 499 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | ||
| 491 | // escape can start while one is in flight. | 500 | // escape can start while one is in flight. | ||
| @@ -582,6 +591,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 582 | fileSuggest: newFileSuggester(), | 591 | fileSuggest: newFileSuggester(), | ||
| 583 | spin: newSpinner(cfg.Theme), | 592 | spin: newSpinner(cfg.Theme), | ||
| 584 | inputHistoryIndex: -1, | 593 | inputHistoryIndex: -1, | ||
| 594 | + | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |||
| 585 | } | 595 | } | ||
| 586 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 596 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 587 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | 597 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | ||
| @@ -906,6 +916,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||||
| 906 | statusErr: i.statusErr, | 916 | statusErr: i.statusErr, | ||
| 907 | help: strings.Join(i.helpBlock, "\n"), | 917 | help: strings.Join(i.helpBlock, "\n"), | ||
| 908 | extNotes: strings.Join(i.extNotes, "\n"), | 918 | extNotes: strings.Join(i.extNotes, "\n"), | ||
| 919 | + | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |||
| 909 | updateAvailable: i.updateInfo.Available, | 920 | updateAvailable: i.updateInfo.Available, | ||
| 910 | updateCurrent: i.updateInfo.Current, | 921 | updateCurrent: i.updateInfo.Current, | ||
| 911 | updateLatest: i.updateInfo.Latest, | 922 | updateLatest: i.updateInfo.Latest, | ||
| @@ -1032,6 +1043,19 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1032 | chat = append(chat, "") | 1043 | chat = append(chat, "") | ||
| 1033 | } | 1044 | } | ||
| 1034 | 1045 | ||||
| 1046 | + | // Reload failures remain in the scrolling chat until /clear. They are | |||
| 1047 | + | // host-only display state, not provider messages or persisted context. | |||
| 1048 | + | // While the latest failure is still the temporary status error, hide its | |||
| 1049 | + | // stored copy to avoid painting the same message twice. | |||
| 1050 | + | reloadErrors := i.reloadErrors | |||
| 1051 | + | if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr { | |||
| 1052 | + | reloadErrors = reloadErrors[:n-1] | |||
| 1053 | + | } | |||
| 1054 | + | if len(reloadErrors) > 0 { | |||
| 1055 | + | chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...) | |||
| 1056 | + | chat = append(chat, "") | |||
| 1057 | + | } | |||
| 1058 | + | ||||
| 1035 | // Strip trailing blank rows so the chat content sits flush | 1059 | // Strip trailing blank rows so the chat content sits flush | ||
| 1036 | // against the new "blank above status bar" row added by the | 1060 | // against the new "blank above status bar" row added by the | ||
| 1037 | // bottom-region assembly. Build() ends every message with a | 1061 | // bottom-region assembly. Build() ends every message with a | ||
| @@ -4068,6 +4092,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4068 | i.parkedTotal = 0 | 4092 | i.parkedTotal = 0 | ||
| 4069 | i.scrollOffset = 0 | 4093 | i.scrollOffset = 0 | ||
| 4070 | i.extNotes = nil | 4094 | i.extNotes = nil | ||
| 4095 | + | i.reloadErrors = nil | |||
| 4071 | i.view.InvalidateRenderCache() | 4096 | i.view.InvalidateRenderCache() | ||
| 4072 | i.mu.Unlock() | 4097 | i.mu.Unlock() | ||
| 4073 | case "/help": | 4098 | case "/help": | ||
M packages/agent/modes/reload_ext.go +26 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | const reloadStatusDuration = 5 * time.Second | |
| @@ -36,6 +37,7 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 { | |||
| 36 | 37 | if failed { | |
| 37 | 38 | i.statusOK = "" | |
| 38 | 39 | i.statusErr = msg | |
| 40 | + | i.reloadErrors = append(i.reloadErrors, msg) | |
| 39 | 41 | return i.reloadStatusSeq | |
| 40 | 42 | } | |
| 41 | 43 | i.statusOK = msg | |
| @@ -43,6 +45,30 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 { | |||
| 43 | 45 | return i.reloadStatusSeq | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 48 | + | func renderReloadErrors(theme tui.Theme, errors []string, width int) []string { | |
| 49 | + | const marker = "✖ " | |
| 50 | + | const indent = " " | |
| 51 | + | wrapWidth := width - len([]rune(marker)) | |
| 52 | + | if wrapWidth < 8 { | |
| 53 | + | wrapWidth = 8 | |
| 54 | + | } | |
| 55 | + | var out []string | |
| 56 | + | for _, msg := range errors { | |
| 57 | + | wrapped := tui.WrapANSILine(msg, wrapWidth) | |
| 58 | + | if len(wrapped) == 0 { | |
| 59 | + | wrapped = []string{""} | |
| 60 | + | } | |
| 61 | + | for idx, line := range wrapped { | |
| 62 | + | prefix := marker | |
| 63 | + | if idx > 0 { | |
| 64 | + | prefix = indent | |
| 65 | + | } | |
| 66 | + | out = append(out, theme.FG256(theme.Error, prefix+line)) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | return out | |
| 70 | + | } | |
| 71 | + | ||
| 46 | 72 | func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool { | |
| 47 | 73 | i.mu.Lock() | |
| 48 | 74 | defer i.mu.Unlock() | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/patriceckhart/zot/packages/agent/extensions" | 9 | "github.com/patriceckhart/zot/packages/agent/extensions" | ||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | const reloadStatusDuration = 5 * time.Second | 13 | const reloadStatusDuration = 5 * time.Second | ||
| @@ -36,6 +37,7 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 { | |||||
| 36 | if failed { | 37 | if failed { | ||
| 37 | i.statusOK = "" | 38 | i.statusOK = "" | ||
| 38 | i.statusErr = msg | 39 | i.statusErr = msg | ||
| 40 | + | i.reloadErrors = append(i.reloadErrors, msg) | |||
| 39 | return i.reloadStatusSeq | 41 | return i.reloadStatusSeq | ||
| 40 | } | 42 | } | ||
| 41 | i.statusOK = msg | 43 | i.statusOK = msg | ||
| @@ -43,6 +45,30 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 { | |||||
| 43 | return i.reloadStatusSeq | 45 | return i.reloadStatusSeq | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 48 | + | func renderReloadErrors(theme tui.Theme, errors []string, width int) []string { | |||
| 49 | + | const marker = "✖ " | |||
| 50 | + | const indent = " " | |||
| 51 | + | wrapWidth := width - len([]rune(marker)) | |||
| 52 | + | if wrapWidth < 8 { | |||
| 53 | + | wrapWidth = 8 | |||
| 54 | + | } | |||
| 55 | + | var out []string | |||
| 56 | + | for _, msg := range errors { | |||
| 57 | + | wrapped := tui.WrapANSILine(msg, wrapWidth) | |||
| 58 | + | if len(wrapped) == 0 { | |||
| 59 | + | wrapped = []string{""} | |||
| 60 | + | } | |||
| 61 | + | for idx, line := range wrapped { | |||
| 62 | + | prefix := marker | |||
| 63 | + | if idx > 0 { | |||
| 64 | + | prefix = indent | |||
| 65 | + | } | |||
| 66 | + | out = append(out, theme.FG256(theme.Error, prefix+line)) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return out | |||
| 70 | + | } | |||
| 71 | + | ||||
| 46 | func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool { | 72 | func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool { | ||
| 47 | i.mu.Lock() | 73 | i.mu.Lock() | ||
| 48 | defer i.mu.Unlock() | 74 | defer i.mu.Unlock() | ||
M packages/agent/modes/reload_ext_test.go +42 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) { | |
| @@ -49,12 +51,52 @@ func TestSetReloadStatusUsesErrorChannelForFailures(t *testing.T) { | |||
| 49 | 51 | if i.statusErr != "broken extension" || i.statusOK != "" { | |
| 50 | 52 | t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | |
| 51 | 53 | } | |
| 54 | + | if len(i.reloadErrors) != 1 || i.reloadErrors[0] != "broken extension" { | |
| 55 | + | t.Fatalf("reload errors = %q, want persistent failure", i.reloadErrors) | |
| 56 | + | } | |
| 52 | 57 | i.setReloadStatus("reload complete", false) | |
| 53 | 58 | if i.statusOK != "reload complete" || i.statusErr != "" { | |
| 54 | 59 | t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | |
| 55 | 60 | } | |
| 56 | 61 | } | |
| 57 | 62 | ||
| 63 | + | func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T) { | |
| 64 | + | agent := core.NewAgent(nil, "", "", nil) | |
| 65 | + | i := NewInteractive(InteractiveConfig{ | |
| 66 | + | Agent: agent, | |
| 67 | + | Theme: tui.Theme{Error: 1}, | |
| 68 | + | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | chat := strings.Join(i.buildChatLocked(30), "\n") | |
| 72 | + | if !strings.Contains(chat, "startup failed") || !strings.Contains(chat, "/tmp/startup.log") { | |
| 73 | + | t.Fatalf("chat does not contain startup extension failure: %q", chat) | |
| 74 | + | } | |
| 75 | + | if got := len(agent.Messages()); got != 0 { | |
| 76 | + | t.Fatalf("agent context contains %d startup error message(s), want none", got) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) { | |
| 81 | + | agent := core.NewAgent(nil, "", "", nil) | |
| 82 | + | i := NewInteractive(InteractiveConfig{ | |
| 83 | + | Agent: agent, | |
| 84 | + | Theme: tui.Theme{Error: 1}, | |
| 85 | + | }) | |
| 86 | + | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | |
| 87 | + | if !i.clearReloadStatus(seq, i.statusErr, true) { | |
| 88 | + | t.Fatal("temporary reload status was not cleared") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | chat := strings.Join(i.buildChatLocked(30), "\n") | |
| 92 | + | if !strings.Contains(chat, "broken extension") || !strings.Contains(chat, "/tmp/ext.log") { | |
| 93 | + | t.Fatalf("chat does not contain persistent reload failure: %q", chat) | |
| 94 | + | } | |
| 95 | + | if got := len(agent.Messages()); got != 0 { | |
| 96 | + | t.Fatalf("agent context contains %d reload error message(s), want none", got) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 58 | 100 | func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) { | |
| 59 | 101 | i := &Interactive{statusErr: "newer error", statusOK: "newer success"} | |
| 60 | 102 | if i.clearReloadStatus(0, "old error", true) { | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | "github.com/patriceckhart/zot/packages/agent/extensions" | 8 | "github.com/patriceckhart/zot/packages/agent/extensions" | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) { | 13 | func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) { | ||
| @@ -49,12 +51,52 @@ func TestSetReloadStatusUsesErrorChannelForFailures(t *testing.T) { | |||||
| 49 | if i.statusErr != "broken extension" || i.statusOK != "" { | 51 | if i.statusErr != "broken extension" || i.statusOK != "" { | ||
| 50 | t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | 52 | t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | ||
| 51 | } | 53 | } | ||
| 54 | + | if len(i.reloadErrors) != 1 || i.reloadErrors[0] != "broken extension" { | |||
| 55 | + | t.Fatalf("reload errors = %q, want persistent failure", i.reloadErrors) | |||
| 56 | + | } | |||
| 52 | i.setReloadStatus("reload complete", false) | 57 | i.setReloadStatus("reload complete", false) | ||
| 53 | if i.statusOK != "reload complete" || i.statusErr != "" { | 58 | if i.statusOK != "reload complete" || i.statusErr != "" { | ||
| 54 | t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | 59 | t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK) | ||
| 55 | } | 60 | } | ||
| 56 | } | 61 | } | ||
| 57 | 62 | ||||
| 63 | + | func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T) { | |||
| 64 | + | agent := core.NewAgent(nil, "", "", nil) | |||
| 65 | + | i := NewInteractive(InteractiveConfig{ | |||
| 66 | + | Agent: agent, | |||
| 67 | + | Theme: tui.Theme{Error: 1}, | |||
| 68 | + | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | |||
| 69 | + | }) | |||
| 70 | + | ||||
| 71 | + | chat := strings.Join(i.buildChatLocked(30), "\n") | |||
| 72 | + | if !strings.Contains(chat, "startup failed") || !strings.Contains(chat, "/tmp/startup.log") { | |||
| 73 | + | t.Fatalf("chat does not contain startup extension failure: %q", chat) | |||
| 74 | + | } | |||
| 75 | + | if got := len(agent.Messages()); got != 0 { | |||
| 76 | + | t.Fatalf("agent context contains %d startup error message(s), want none", got) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) { | |||
| 81 | + | agent := core.NewAgent(nil, "", "", nil) | |||
| 82 | + | i := NewInteractive(InteractiveConfig{ | |||
| 83 | + | Agent: agent, | |||
| 84 | + | Theme: tui.Theme{Error: 1}, | |||
| 85 | + | }) | |||
| 86 | + | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | |||
| 87 | + | if !i.clearReloadStatus(seq, i.statusErr, true) { | |||
| 88 | + | t.Fatal("temporary reload status was not cleared") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | chat := strings.Join(i.buildChatLocked(30), "\n") | |||
| 92 | + | if !strings.Contains(chat, "broken extension") || !strings.Contains(chat, "/tmp/ext.log") { | |||
| 93 | + | t.Fatalf("chat does not contain persistent reload failure: %q", chat) | |||
| 94 | + | } | |||
| 95 | + | if got := len(agent.Messages()); got != 0 { | |||
| 96 | + | t.Fatalf("agent context contains %d reload error message(s), want none", got) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 58 | func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) { | 100 | func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) { | ||
| 59 | i := &Interactive{statusErr: "newer error", statusOK: "newer success"} | 101 | i := &Interactive{statusErr: "newer error", statusOK: "newer success"} | ||
| 60 | if i.clearReloadStatus(0, "old error", true) { | 102 | if i.clearReloadStatus(0, "old error", true) { | ||