Fix zotfile startup handling
- commit
- 73513b2e343672f68efc1f9b9bc12cad23a7f30e
- author
- patriceckhart · · 2026-08-02 09:15 UTC
- parent
- 189298b
- signature
- unsigned
7 files · +140 -35
M docs/zotfiles.md +10 -4
| @@ -523,12 +523,18 @@ Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back t | |||
| 523 | 523 | ||
| 524 | 524 | A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root: | |
| 525 | 525 | ||
| 526 | + | ```bash | |
| 527 | + | zot run frkr/zot-archify | |
| 528 | + | ``` | |
| 529 | + | ||
| 530 | + | The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection: | |
| 531 | + | ||
| 526 | 532 | ```bash | |
| 527 | 533 | zot run agents/zot-maintenance | |
| 528 | 534 | zot run agents\zot-maintenance # also accepted on Windows | |
| 529 | 535 | ``` | |
| 530 | 536 | ||
| 531 | - | The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted. | |
| 537 | + | Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted. | |
| 532 | 538 | ||
| 533 | 539 | For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits: | |
| 534 | 540 | ||
| @@ -536,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel | |||
| 536 | 542 | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | |
| 537 | 543 | ``` | |
| 538 | 544 | ||
| 539 | - | Both the short subdirectory form above and a standard GitHub tree URL are supported: | |
| 545 | + | Both the legacy official-collection form above and a standard GitHub tree URL are supported: | |
| 540 | 546 | ||
| 541 | 547 | ```bash | |
| 542 | 548 | zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | |
| 543 | 549 | ``` | |
| 544 | 550 | ||
| 545 | - | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | |
| 551 | + | Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | |
| 546 | 552 | ||
| 547 | - | Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | |
| 553 | + | Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | |
| 548 | 554 | ||
| 549 | 555 | ### `zot pack` | |
| 550 | 556 | ||
| @@ -523,12 +523,18 @@ Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back t | |||||
| 523 | 523 | ||||
| 524 | A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root: | 524 | A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root: | ||
| 525 | 525 | ||||
| 526 | + | ```bash | |||
| 527 | + | zot run frkr/zot-archify | |||
| 528 | + | ``` | |||
| 529 | + | ||||
| 530 | + | The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection: | |||
| 531 | + | ||||
| 526 | ```bash | 532 | ```bash | ||
| 527 | zot run agents/zot-maintenance | 533 | zot run agents/zot-maintenance | ||
| 528 | zot run agents\zot-maintenance # also accepted on Windows | 534 | zot run agents\zot-maintenance # also accepted on Windows | ||
| 529 | ``` | 535 | ``` | ||
| 530 | 536 | ||||
| 531 | - | The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted. | 537 | + | Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted. |
| 532 | 538 | ||||
| 533 | For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits: | 539 | For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits: | ||
| 534 | 540 | ||||
| @@ -536,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel | |||||
| 536 | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | 542 | zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot | ||
| 537 | ``` | 543 | ``` | ||
| 538 | 544 | ||||
| 539 | - | Both the short subdirectory form above and a standard GitHub tree URL are supported: | 545 | + | Both the legacy official-collection form above and a standard GitHub tree URL are supported: |
| 540 | 546 | ||||
| 541 | ```bash | 547 | ```bash | ||
| 542 | zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | 548 | zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | ||
| 543 | ``` | 549 | ``` | ||
| 544 | 550 | ||||
| 545 | - | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | 551 | + | Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. |
| 546 | 552 | ||||
| 547 | - | Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | 553 | + | Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. |
| 548 | 554 | ||||
| 549 | ### `zot pack` | 555 | ### `zot pack` | ||
| 550 | 556 | ||||
M packages/agent/cli.go +45 -20
| @@ -391,35 +391,60 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | 393 | start := len(ag.Messages()) | |
| 394 | - | preSink := func(ev core.AgentEvent) { | |
| 394 | + | preSink, finishPre := newStreamTextSink(os.Stdout) | |
| 395 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |
| 396 | + | finishPre() | |
| 397 | + | return err | |
| 398 | + | } | |
| 399 | + | finishPre() | |
| 400 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 401 | + | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 402 | + | WriteNewTranscript(ag, sess, start) | |
| 403 | + | return err | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) { | |
| 407 | + | var streamed, wroteText, lastWasNL bool | |
| 408 | + | writeText := func(text string) { | |
| 409 | + | if text == "" { | |
| 410 | + | return | |
| 411 | + | } | |
| 412 | + | _, _ = fmt.Fprint(out, text) | |
| 413 | + | if syncer, ok := out.(interface{ Sync() error }); ok { | |
| 414 | + | _ = syncer.Sync() | |
| 415 | + | } | |
| 416 | + | wroteText = true | |
| 417 | + | lastWasNL = strings.HasSuffix(text, "\n") | |
| 418 | + | } | |
| 419 | + | sink := func(ev core.AgentEvent) { | |
| 395 | 420 | switch e := ev.(type) { | |
| 421 | + | case core.EvAssistantStart: | |
| 422 | + | streamed = false | |
| 396 | 423 | case core.EvTextDelta: | |
| 397 | - | fmt.Fprint(os.Stdout, e.Delta) | |
| 398 | - | _ = os.Stdout.Sync() | |
| 424 | + | streamed = true | |
| 425 | + | writeText(e.Delta) | |
| 399 | 426 | case core.EvAssistantMessage: | |
| 400 | - | // Fallback when the provider does not emit deltas. | |
| 401 | - | var sb strings.Builder | |
| 402 | - | for _, c := range e.Message.Content { | |
| 403 | - | if tb, ok := c.(provider.TextBlock); ok { | |
| 404 | - | if sb.Len() > 0 { | |
| 405 | - | sb.WriteString("\n") | |
| 427 | + | if streamed { | |
| 428 | + | return | |
| 429 | + | } | |
| 430 | + | var text strings.Builder | |
| 431 | + | for _, content := range e.Message.Content { | |
| 432 | + | if block, ok := content.(provider.TextBlock); ok { | |
| 433 | + | if text.Len() > 0 { | |
| 434 | + | text.WriteString("\n") | |
| 406 | 435 | } | |
| 407 | - | sb.WriteString(tb.Text) | |
| 436 | + | text.WriteString(block.Text) | |
| 408 | 437 | } | |
| 409 | 438 | } | |
| 410 | - | if sb.Len() > 0 { | |
| 411 | - | fmt.Fprint(os.Stdout, sb.String()) | |
| 412 | - | _ = os.Stdout.Sync() | |
| 413 | - | } | |
| 439 | + | writeText(text.String()) | |
| 414 | 440 | } | |
| 415 | 441 | } | |
| 416 | - | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |
| 417 | - | return err | |
| 442 | + | finish := func() { | |
| 443 | + | if wroteText && !lastWasNL { | |
| 444 | + | writeText("\n") | |
| 445 | + | } | |
| 418 | 446 | } | |
| 419 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 420 | - | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 421 | - | WriteNewTranscript(ag, sess, start) | |
| 422 | - | return err | |
| 447 | + | return sink, finish | |
| 423 | 448 | } | |
| 424 | 449 | ||
| 425 | 450 | func runJSONMode(ctx context.Context, args Args, version string) error { | |
| @@ -391,35 +391,60 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||||
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | start := len(ag.Messages()) | 393 | start := len(ag.Messages()) | ||
| 394 | - | preSink := func(ev core.AgentEvent) { | 394 | + | preSink, finishPre := newStreamTextSink(os.Stdout) |
| 395 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |||
| 396 | + | finishPre() | |||
| 397 | + | return err | |||
| 398 | + | } | |||
| 399 | + | finishPre() | |||
| 400 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |||
| 401 | + | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |||
| 402 | + | WriteNewTranscript(ag, sess, start) | |||
| 403 | + | return err | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) { | |||
| 407 | + | var streamed, wroteText, lastWasNL bool | |||
| 408 | + | writeText := func(text string) { | |||
| 409 | + | if text == "" { | |||
| 410 | + | return | |||
| 411 | + | } | |||
| 412 | + | _, _ = fmt.Fprint(out, text) | |||
| 413 | + | if syncer, ok := out.(interface{ Sync() error }); ok { | |||
| 414 | + | _ = syncer.Sync() | |||
| 415 | + | } | |||
| 416 | + | wroteText = true | |||
| 417 | + | lastWasNL = strings.HasSuffix(text, "\n") | |||
| 418 | + | } | |||
| 419 | + | sink := func(ev core.AgentEvent) { | |||
| 395 | switch e := ev.(type) { | 420 | switch e := ev.(type) { | ||
| 421 | + | case core.EvAssistantStart: | |||
| 422 | + | streamed = false | |||
| 396 | case core.EvTextDelta: | 423 | case core.EvTextDelta: | ||
| 397 | - | fmt.Fprint(os.Stdout, e.Delta) | 424 | + | streamed = true |
| 398 | - | _ = os.Stdout.Sync() | 425 | + | writeText(e.Delta) |
| 399 | case core.EvAssistantMessage: | 426 | case core.EvAssistantMessage: | ||
| 400 | - | // Fallback when the provider does not emit deltas. | 427 | + | if streamed { |
| 401 | - | var sb strings.Builder | 428 | + | return |
| 402 | - | for _, c := range e.Message.Content { | 429 | + | } |
| 403 | - | if tb, ok := c.(provider.TextBlock); ok { | 430 | + | var text strings.Builder |
| 404 | - | if sb.Len() > 0 { | 431 | + | for _, content := range e.Message.Content { |
| 405 | - | sb.WriteString("\n") | 432 | + | if block, ok := content.(provider.TextBlock); ok { |
| 433 | + | if text.Len() > 0 { | |||
| 434 | + | text.WriteString("\n") | |||
| 406 | } | 435 | } | ||
| 407 | - | sb.WriteString(tb.Text) | 436 | + | text.WriteString(block.Text) |
| 408 | } | 437 | } | ||
| 409 | } | 438 | } | ||
| 410 | - | if sb.Len() > 0 { | 439 | + | writeText(text.String()) |
| 411 | - | fmt.Fprint(os.Stdout, sb.String()) | |||
| 412 | - | _ = os.Stdout.Sync() | |||
| 413 | - | } | |||
| 414 | } | 440 | } | ||
| 415 | } | 441 | } | ||
| 416 | - | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | 442 | + | finish := func() { |
| 417 | - | return err | 443 | + | if wroteText && !lastWasNL { |
| 444 | + | writeText("\n") | |||
| 445 | + | } | |||
| 418 | } | 446 | } | ||
| 419 | - | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | 447 | + | return sink, finish |
| 420 | - | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |||
| 421 | - | WriteNewTranscript(ag, sess, start) | |||
| 422 | - | return err | |||
| 423 | } | 448 | } | ||
| 424 | 449 | ||||
| 425 | func runJSONMode(ctx context.Context, args Args, version string) error { | 450 | func runJSONMode(ctx context.Context, args Args, version string) error { | ||
M packages/agent/modes/interactive.go +27 -6
| @@ -401,6 +401,7 @@ type Interactive struct { | |||
| 401 | 401 | busy bool | |
| 402 | 402 | dirty chan struct{} | |
| 403 | 403 | modelRefresh chan modelRefreshResult | |
| 404 | + | startupPreDone chan startupPreResult | |
| 404 | 405 | cancelTurn context.CancelFunc | |
| 405 | 406 | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 406 | 407 | prevScrollOffset int // last value redraw snapped against; tracks intent | |
| @@ -584,6 +585,11 @@ const initialResumeTailLimit = 80 | |||
| 584 | 585 | // reveal that would feel jerky. | |
| 585 | 586 | const resumeTailExpandStep = 80 | |
| 586 | 587 | ||
| 588 | + | type startupPreResult struct { | |
| 589 | + | deferred string | |
| 590 | + | autoSubmit bool | |
| 591 | + | } | |
| 592 | + | ||
| 587 | 593 | // NewInteractive constructs an Interactive from cfg. | |
| 588 | 594 | func NewInteractive(cfg InteractiveConfig) *Interactive { | |
| 589 | 595 | renderer := tui.NewRenderer(cfg.Terminal) | |
| @@ -620,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 620 | 626 | toolGate: map[string]int{}, | |
| 621 | 627 | dirty: make(chan struct{}, 8), | |
| 622 | 628 | modelRefresh: make(chan modelRefreshResult, 1), | |
| 629 | + | startupPreDone: make(chan startupPreResult, 1), | |
| 623 | 630 | dialog: newLoginDialog(), | |
| 624 | 631 | modelDialog: newModelDialog(), | |
| 625 | 632 | llamaDialog: newLlamaDialog(), | |
| @@ -884,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 884 | 891 | case result := <-i.modelRefresh: | |
| 885 | 892 | i.openModelPickerAfterRefresh(result.err) | |
| 886 | 893 | i.invalidate() | |
| 894 | + | case result := <-i.startupPreDone: | |
| 895 | + | i.applyStartupPreResult(result) | |
| 896 | + | i.invalidate() | |
| 887 | 897 | case info, ok := <-updates: | |
| 888 | 898 | if ok && info.Available { | |
| 889 | 899 | i.mu.Lock() | |
| @@ -3002,15 +3012,24 @@ func (i *Interactive) completeStartupPre() { | |||
| 3002 | 3012 | if onDone != nil { | |
| 3003 | 3013 | onDone() | |
| 3004 | 3014 | } | |
| 3005 | - | if deferred == "" { | |
| 3015 | + | i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto} | |
| 3016 | + | i.invalidate() | |
| 3017 | + | } | |
| 3018 | + | ||
| 3019 | + | // applyStartupPreResult runs on the TUI event loop so the editor remains | |
| 3020 | + | // single-threaded. Input entered while resources were reloading wins over the | |
| 3021 | + | // deferred prefill rather than being overwritten. | |
| 3022 | + | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |
| 3023 | + | if result.deferred == "" { | |
| 3006 | 3024 | return | |
| 3007 | 3025 | } | |
| 3008 | - | if auto { | |
| 3009 | - | i.Submit(deferred) | |
| 3026 | + | if result.autoSubmit { | |
| 3027 | + | i.Submit(result.deferred) | |
| 3010 | 3028 | return | |
| 3011 | 3029 | } | |
| 3012 | - | i.ed.SetValue(deferred) | |
| 3013 | - | i.invalidate() | |
| 3030 | + | if i.ed.IsEmpty() { | |
| 3031 | + | i.ed.SetValue(result.deferred) | |
| 3032 | + | } | |
| 3014 | 3033 | } | |
| 3015 | 3034 | ||
| 3016 | 3035 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| @@ -5526,7 +5545,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5526 | 5545 | out += "\n\n[cancelled]" | |
| 5527 | 5546 | } | |
| 5528 | 5547 | ||
| 5529 | - | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |
| 5548 | + | if i.agent != nil { | |
| 5549 | + | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |
| 5550 | + | } | |
| 5530 | 5551 | ||
| 5531 | 5552 | i.mu.Lock() | |
| 5532 | 5553 | i.shellRunning = false | |
| @@ -401,6 +401,7 @@ type Interactive struct { | |||||
| 401 | busy bool | 401 | busy bool | ||
| 402 | dirty chan struct{} | 402 | dirty chan struct{} | ||
| 403 | modelRefresh chan modelRefreshResult | 403 | modelRefresh chan modelRefreshResult | ||
| 404 | + | startupPreDone chan startupPreResult | |||
| 404 | cancelTurn context.CancelFunc | 405 | cancelTurn context.CancelFunc | ||
| 405 | scrollOffset int // rows from the bottom; 0 = pinned to latest | 406 | scrollOffset int // rows from the bottom; 0 = pinned to latest | ||
| 406 | prevScrollOffset int // last value redraw snapped against; tracks intent | 407 | prevScrollOffset int // last value redraw snapped against; tracks intent | ||
| @@ -584,6 +585,11 @@ const initialResumeTailLimit = 80 | |||||
| 584 | // reveal that would feel jerky. | 585 | // reveal that would feel jerky. | ||
| 585 | const resumeTailExpandStep = 80 | 586 | const resumeTailExpandStep = 80 | ||
| 586 | 587 | ||||
| 588 | + | type startupPreResult struct { | |||
| 589 | + | deferred string | |||
| 590 | + | autoSubmit bool | |||
| 591 | + | } | |||
| 592 | + | ||||
| 587 | // NewInteractive constructs an Interactive from cfg. | 593 | // NewInteractive constructs an Interactive from cfg. | ||
| 588 | func NewInteractive(cfg InteractiveConfig) *Interactive { | 594 | func NewInteractive(cfg InteractiveConfig) *Interactive { | ||
| 589 | renderer := tui.NewRenderer(cfg.Terminal) | 595 | renderer := tui.NewRenderer(cfg.Terminal) | ||
| @@ -620,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 620 | toolGate: map[string]int{}, | 626 | toolGate: map[string]int{}, | ||
| 621 | dirty: make(chan struct{}, 8), | 627 | dirty: make(chan struct{}, 8), | ||
| 622 | modelRefresh: make(chan modelRefreshResult, 1), | 628 | modelRefresh: make(chan modelRefreshResult, 1), | ||
| 629 | + | startupPreDone: make(chan startupPreResult, 1), | |||
| 623 | dialog: newLoginDialog(), | 630 | dialog: newLoginDialog(), | ||
| 624 | modelDialog: newModelDialog(), | 631 | modelDialog: newModelDialog(), | ||
| 625 | llamaDialog: newLlamaDialog(), | 632 | llamaDialog: newLlamaDialog(), | ||
| @@ -884,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 884 | case result := <-i.modelRefresh: | 891 | case result := <-i.modelRefresh: | ||
| 885 | i.openModelPickerAfterRefresh(result.err) | 892 | i.openModelPickerAfterRefresh(result.err) | ||
| 886 | i.invalidate() | 893 | i.invalidate() | ||
| 894 | + | case result := <-i.startupPreDone: | |||
| 895 | + | i.applyStartupPreResult(result) | |||
| 896 | + | i.invalidate() | |||
| 887 | case info, ok := <-updates: | 897 | case info, ok := <-updates: | ||
| 888 | if ok && info.Available { | 898 | if ok && info.Available { | ||
| 889 | i.mu.Lock() | 899 | i.mu.Lock() | ||
| @@ -3002,15 +3012,24 @@ func (i *Interactive) completeStartupPre() { | |||||
| 3002 | if onDone != nil { | 3012 | if onDone != nil { | ||
| 3003 | onDone() | 3013 | onDone() | ||
| 3004 | } | 3014 | } | ||
| 3005 | - | if deferred == "" { | 3015 | + | i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto} |
| 3016 | + | i.invalidate() | |||
| 3017 | + | } | |||
| 3018 | + | ||||
| 3019 | + | // applyStartupPreResult runs on the TUI event loop so the editor remains | |||
| 3020 | + | // single-threaded. Input entered while resources were reloading wins over the | |||
| 3021 | + | // deferred prefill rather than being overwritten. | |||
| 3022 | + | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |||
| 3023 | + | if result.deferred == "" { | |||
| 3006 | return | 3024 | return | ||
| 3007 | } | 3025 | } | ||
| 3008 | - | if auto { | 3026 | + | if result.autoSubmit { |
| 3009 | - | i.Submit(deferred) | 3027 | + | i.Submit(result.deferred) |
| 3010 | return | 3028 | return | ||
| 3011 | } | 3029 | } | ||
| 3012 | - | i.ed.SetValue(deferred) | 3030 | + | if i.ed.IsEmpty() { |
| 3013 | - | i.invalidate() | 3031 | + | i.ed.SetValue(result.deferred) |
| 3032 | + | } | |||
| 3014 | } | 3033 | } | ||
| 3015 | 3034 | ||||
| 3016 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | 3035 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | ||
| @@ -5526,7 +5545,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5526 | out += "\n\n[cancelled]" | 5545 | out += "\n\n[cancelled]" | ||
| 5527 | } | 5546 | } | ||
| 5528 | 5547 | ||||
| 5529 | - | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | 5548 | + | if i.agent != nil { |
| 5549 | + | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |||
| 5550 | + | } | |||
| 5530 | 5551 | ||||
| 5531 | i.mu.Lock() | 5552 | i.mu.Lock() | ||
| 5532 | i.shellRunning = false | 5553 | i.shellRunning = false | ||
M packages/agent/modes/startup_pre_test.go +32 -3
| @@ -10,12 +10,23 @@ import ( | |||
| 10 | 10 | "github.com/patriceckhart/zot/packages/core" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | func applyStartupPreResultForTest(t *testing.T, i *Interactive) { | |
| 14 | + | t.Helper() | |
| 15 | + | select { | |
| 16 | + | case result := <-i.startupPreDone: | |
| 17 | + | i.applyStartupPreResult(result) | |
| 18 | + | default: | |
| 19 | + | t.Fatal("startup pre result was not queued") | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 13 | 23 | func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | |
| 14 | 24 | i := NewInteractive(InteractiveConfig{}) | |
| 15 | 25 | i.awaitingStartupPre = true | |
| 16 | 26 | i.deferredInitialInput = "Bom dia!" | |
| 17 | 27 | i.autoSubmitDeferred = false | |
| 18 | 28 | i.completeStartupPre() | |
| 29 | + | applyStartupPreResultForTest(t, i) | |
| 19 | 30 | if got := i.ed.Value(); got != "Bom dia!" { | |
| 20 | 31 | t.Fatalf("editor = %q, want Bom dia!", got) | |
| 21 | 32 | } | |
| @@ -27,6 +38,15 @@ func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | |||
| 27 | 38 | } | |
| 28 | 39 | } | |
| 29 | 40 | ||
| 41 | + | func TestStartupPrePrefillPreservesTypedInput(t *testing.T) { | |
| 42 | + | i := NewInteractive(InteractiveConfig{}) | |
| 43 | + | i.ed.SetValue("typed while reloading") | |
| 44 | + | i.applyStartupPreResult(startupPreResult{deferred: "default prompt"}) | |
| 45 | + | if got := i.ed.Value(); got != "typed while reloading" { | |
| 46 | + | t.Fatalf("editor = %q, want typed input to be preserved", got) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 30 | 50 | func TestCompleteStartupPreCallsOnDone(t *testing.T) { | |
| 31 | 51 | called := false | |
| 32 | 52 | i := NewInteractive(InteractiveConfig{ | |
| @@ -39,9 +59,8 @@ func TestCompleteStartupPreCallsOnDone(t *testing.T) { | |||
| 39 | 59 | } | |
| 40 | 60 | } | |
| 41 | 61 | ||
| 42 | - | func TestStartupPreShellThenPrefill(t *testing.T) { | |
| 43 | - | agent := core.NewAgent(nil, "", "", nil) | |
| 44 | - | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |
| 62 | + | func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) { | |
| 63 | + | i := NewInteractive(InteractiveConfig{CWD: t.TempDir()}) | |
| 45 | 64 | i.runCtx = context.Background() | |
| 46 | 65 | i.awaitingStartupPre = true | |
| 47 | 66 | i.deferredInitialInput = "after-pre" | |
| @@ -55,6 +74,11 @@ func TestStartupPreShellThenPrefill(t *testing.T) { | |||
| 55 | 74 | ||
| 56 | 75 | deadline := time.Now().Add(5 * time.Second) | |
| 57 | 76 | for time.Now().Before(deadline) { | |
| 77 | + | select { | |
| 78 | + | case result := <-i.startupPreDone: | |
| 79 | + | i.applyStartupPreResult(result) | |
| 80 | + | default: | |
| 81 | + | } | |
| 58 | 82 | i.mu.Lock() | |
| 59 | 83 | running := i.shellRunning | |
| 60 | 84 | awaiting := i.awaitingStartupPre | |
| @@ -89,6 +113,11 @@ func TestStartupPreShellThenAutoSubmitShell(t *testing.T) { | |||
| 89 | 113 | ||
| 90 | 114 | deadline := time.Now().Add(5 * time.Second) | |
| 91 | 115 | for time.Now().Before(deadline) { | |
| 116 | + | select { | |
| 117 | + | case result := <-i.startupPreDone: | |
| 118 | + | i.applyStartupPreResult(result) | |
| 119 | + | default: | |
| 120 | + | } | |
| 92 | 121 | messages := agent.Messages() | |
| 93 | 122 | i.mu.Lock() | |
| 94 | 123 | running := i.shellRunning || i.busy | |
| @@ -10,12 +10,23 @@ import ( | |||||
| 10 | "github.com/patriceckhart/zot/packages/core" | 10 | "github.com/patriceckhart/zot/packages/core" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func applyStartupPreResultForTest(t *testing.T, i *Interactive) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | select { | |||
| 16 | + | case result := <-i.startupPreDone: | |||
| 17 | + | i.applyStartupPreResult(result) | |||
| 18 | + | default: | |||
| 19 | + | t.Fatal("startup pre result was not queued") | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 13 | func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | 23 | func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | ||
| 14 | i := NewInteractive(InteractiveConfig{}) | 24 | i := NewInteractive(InteractiveConfig{}) | ||
| 15 | i.awaitingStartupPre = true | 25 | i.awaitingStartupPre = true | ||
| 16 | i.deferredInitialInput = "Bom dia!" | 26 | i.deferredInitialInput = "Bom dia!" | ||
| 17 | i.autoSubmitDeferred = false | 27 | i.autoSubmitDeferred = false | ||
| 18 | i.completeStartupPre() | 28 | i.completeStartupPre() | ||
| 29 | + | applyStartupPreResultForTest(t, i) | |||
| 19 | if got := i.ed.Value(); got != "Bom dia!" { | 30 | if got := i.ed.Value(); got != "Bom dia!" { | ||
| 20 | t.Fatalf("editor = %q, want Bom dia!", got) | 31 | t.Fatalf("editor = %q, want Bom dia!", got) | ||
| 21 | } | 32 | } | ||
| @@ -27,6 +38,15 @@ func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | |||||
| 27 | } | 38 | } | ||
| 28 | } | 39 | } | ||
| 29 | 40 | ||||
| 41 | + | func TestStartupPrePrefillPreservesTypedInput(t *testing.T) { | |||
| 42 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 43 | + | i.ed.SetValue("typed while reloading") | |||
| 44 | + | i.applyStartupPreResult(startupPreResult{deferred: "default prompt"}) | |||
| 45 | + | if got := i.ed.Value(); got != "typed while reloading" { | |||
| 46 | + | t.Fatalf("editor = %q, want typed input to be preserved", got) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 30 | func TestCompleteStartupPreCallsOnDone(t *testing.T) { | 50 | func TestCompleteStartupPreCallsOnDone(t *testing.T) { | ||
| 31 | called := false | 51 | called := false | ||
| 32 | i := NewInteractive(InteractiveConfig{ | 52 | i := NewInteractive(InteractiveConfig{ | ||
| @@ -39,9 +59,8 @@ func TestCompleteStartupPreCallsOnDone(t *testing.T) { | |||||
| 39 | } | 59 | } | ||
| 40 | } | 60 | } | ||
| 41 | 61 | ||||
| 42 | - | func TestStartupPreShellThenPrefill(t *testing.T) { | 62 | + | func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) { |
| 43 | - | agent := core.NewAgent(nil, "", "", nil) | 63 | + | i := NewInteractive(InteractiveConfig{CWD: t.TempDir()}) |
| 44 | - | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |||
| 45 | i.runCtx = context.Background() | 64 | i.runCtx = context.Background() | ||
| 46 | i.awaitingStartupPre = true | 65 | i.awaitingStartupPre = true | ||
| 47 | i.deferredInitialInput = "after-pre" | 66 | i.deferredInitialInput = "after-pre" | ||
| @@ -55,6 +74,11 @@ func TestStartupPreShellThenPrefill(t *testing.T) { | |||||
| 55 | 74 | ||||
| 56 | deadline := time.Now().Add(5 * time.Second) | 75 | deadline := time.Now().Add(5 * time.Second) | ||
| 57 | for time.Now().Before(deadline) { | 76 | for time.Now().Before(deadline) { | ||
| 77 | + | select { | |||
| 78 | + | case result := <-i.startupPreDone: | |||
| 79 | + | i.applyStartupPreResult(result) | |||
| 80 | + | default: | |||
| 81 | + | } | |||
| 58 | i.mu.Lock() | 82 | i.mu.Lock() | ||
| 59 | running := i.shellRunning | 83 | running := i.shellRunning | ||
| 60 | awaiting := i.awaitingStartupPre | 84 | awaiting := i.awaitingStartupPre | ||
| @@ -89,6 +113,11 @@ func TestStartupPreShellThenAutoSubmitShell(t *testing.T) { | |||||
| 89 | 113 | ||||
| 90 | deadline := time.Now().Add(5 * time.Second) | 114 | deadline := time.Now().Add(5 * time.Second) | ||
| 91 | for time.Now().Before(deadline) { | 115 | for time.Now().Before(deadline) { | ||
| 116 | + | select { | |||
| 117 | + | case result := <-i.startupPreDone: | |||
| 118 | + | i.applyStartupPreResult(result) | |||
| 119 | + | default: | |||
| 120 | + | } | |||
| 92 | messages := agent.Messages() | 121 | messages := agent.Messages() | ||
| 93 | i.mu.Lock() | 122 | i.mu.Lock() | ||
| 94 | running := i.shellRunning || i.busy | 123 | running := i.shellRunning || i.busy | ||
M packages/agent/zotfile.go +5 -0
| @@ -411,6 +411,11 @@ func resolveZotfileRef(ref string) (string, error) { | |||
| 411 | 411 | return officialZotfileCollection + "/" + parts[0], nil | |
| 412 | 412 | } | |
| 413 | 413 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | |
| 414 | + | // Preserve the original official-collection shorthand, where | |
| 415 | + | // agents/<name> selects a subdirectory of patriceckhart/agents. | |
| 416 | + | if parts[0] == "agents" { | |
| 417 | + | return officialZotfileCollection + "/" + parts[1], nil | |
| 418 | + | } | |
| 414 | 419 | return "https://github.com/" + parts[0] + "/" + parts[1], nil | |
| 415 | 420 | } | |
| 416 | 421 | return ref, nil | |
| @@ -411,6 +411,11 @@ func resolveZotfileRef(ref string) (string, error) { | |||||
| 411 | return officialZotfileCollection + "/" + parts[0], nil | 411 | return officialZotfileCollection + "/" + parts[0], nil | ||
| 412 | } | 412 | } | ||
| 413 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | 413 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | ||
| 414 | + | // Preserve the original official-collection shorthand, where | |||
| 415 | + | // agents/<name> selects a subdirectory of patriceckhart/agents. | |||
| 416 | + | if parts[0] == "agents" { | |||
| 417 | + | return officialZotfileCollection + "/" + parts[1], nil | |||
| 418 | + | } | |||
| 414 | return "https://github.com/" + parts[0] + "/" + parts[1], nil | 419 | return "https://github.com/" + parts[0] + "/" + parts[1], nil | ||
| 415 | } | 420 | } | ||
| 416 | return ref, nil | 421 | return ref, nil | ||
M packages/agent/zotfile_pre_test.go +19 -0
| @@ -1,12 +1,31 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "runtime" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| 14 | + | func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) { | |
| 15 | + | var out bytes.Buffer | |
| 16 | + | sink, finish := newStreamTextSink(&out) | |
| 17 | + | sink(core.EvAssistantStart{}) | |
| 18 | + | sink(core.EvTextDelta{Delta: "streamed"}) | |
| 19 | + | sink(core.EvAssistantMessage{Message: provider.Message{ | |
| 20 | + | Role: provider.RoleAssistant, | |
| 21 | + | Content: []provider.Content{provider.TextBlock{Text: "streamed"}}, | |
| 22 | + | }}) | |
| 23 | + | finish() | |
| 24 | + | if got := out.String(); got != "streamed\n" { | |
| 25 | + | t.Fatalf("output = %q, want streamed\\n", got) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 10 | 29 | func TestRunZotfileStartupPreShellFailure(t *testing.T) { | |
| 11 | 30 | pre := "!false" | |
| 12 | 31 | if runtime.GOOS == "windows" { | |
| @@ -1,12 +1,31 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "runtime" | 6 | "runtime" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | ) | 12 | ) | ||
| 9 | 13 | ||||
| 14 | + | func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) { | |||
| 15 | + | var out bytes.Buffer | |||
| 16 | + | sink, finish := newStreamTextSink(&out) | |||
| 17 | + | sink(core.EvAssistantStart{}) | |||
| 18 | + | sink(core.EvTextDelta{Delta: "streamed"}) | |||
| 19 | + | sink(core.EvAssistantMessage{Message: provider.Message{ | |||
| 20 | + | Role: provider.RoleAssistant, | |||
| 21 | + | Content: []provider.Content{provider.TextBlock{Text: "streamed"}}, | |||
| 22 | + | }}) | |||
| 23 | + | finish() | |||
| 24 | + | if got := out.String(); got != "streamed\n" { | |||
| 25 | + | t.Fatalf("output = %q, want streamed\\n", got) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 10 | func TestRunZotfileStartupPreShellFailure(t *testing.T) { | 29 | func TestRunZotfileStartupPreShellFailure(t *testing.T) { | ||
| 11 | pre := "!false" | 30 | pre := "!false" | ||
| 12 | if runtime.GOOS == "windows" { | 31 | if runtime.GOOS == "windows" { | ||
M packages/agent/zotfile_test.go +2 -2
| @@ -180,8 +180,8 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) { | |||
| 180 | 180 | {"packed-agent", "packed-agent.zot"}, | |
| 181 | 181 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | |
| 182 | 182 | {"missing.zot", "missing.zot"}, | |
| 183 | - | {"agents/remote-agent", "https://github.com/agents/remote-agent"}, | |
| 184 | - | {`agents\remote-agent`, "https://github.com/agents/remote-agent"}, | |
| 183 | + | {"agents/remote-agent", officialZotfileCollection + "/remote-agent"}, | |
| 184 | + | {`agents\remote-agent`, officialZotfileCollection + "/remote-agent"}, | |
| 185 | 185 | {"frkr/zot-archify", "https://github.com/frkr/zot-archify"}, | |
| 186 | 186 | {"./missing-agent", "./missing-agent"}, | |
| 187 | 187 | {"Remote-Agent", "Remote-Agent"}, | |
| @@ -180,8 +180,8 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) { | |||||
| 180 | {"packed-agent", "packed-agent.zot"}, | 180 | {"packed-agent", "packed-agent.zot"}, | ||
| 181 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | 181 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | ||
| 182 | {"missing.zot", "missing.zot"}, | 182 | {"missing.zot", "missing.zot"}, | ||
| 183 | - | {"agents/remote-agent", "https://github.com/agents/remote-agent"}, | 183 | + | {"agents/remote-agent", officialZotfileCollection + "/remote-agent"}, |
| 184 | - | {`agents\remote-agent`, "https://github.com/agents/remote-agent"}, | 184 | + | {`agents\remote-agent`, officialZotfileCollection + "/remote-agent"}, |
| 185 | {"frkr/zot-archify", "https://github.com/frkr/zot-archify"}, | 185 | {"frkr/zot-archify", "https://github.com/frkr/zot-archify"}, | ||
| 186 | {"./missing-agent", "./missing-agent"}, | 186 | {"./missing-agent", "./missing-agent"}, | ||
| 187 | {"Remote-Agent", "Remote-Agent"}, | 187 | {"Remote-Agent", "Remote-Agent"}, | ||