mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-19 13:54:20 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dddf10bc36 | |||
| 888c6c7953 | |||
| a9d808eb9f | |||
| d7948a64f3 | |||
| d2e2e5e9b3 | |||
| 2c05280150 |
@@ -69,6 +69,9 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont
|
||||
}
|
||||
appInstance.RunWithFiles(text, parts)
|
||||
}
|
||||
ec.NewSession = func(prompt string) error {
|
||||
return appInstance.RequestNewSessionFromExtension(prompt)
|
||||
}
|
||||
ec.GetSessionUsage = func() extensions.SessionUsage {
|
||||
if usageTracker == nil {
|
||||
return extensions.SessionUsage{}
|
||||
|
||||
+8
-5
@@ -670,13 +670,16 @@ func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, strin
|
||||
|
||||
// beforeSessionSwitchProviderForUI returns a callback that emits a
|
||||
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
|
||||
// if extensions are disabled — the UI treats nil as "no hook".
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
|
||||
// if extensions are disabled — the UI treats nil as "no hook". The
|
||||
// initialPrompt argument is forwarded to the event so extensions can
|
||||
// inspect the prompt that will be submitted as the first turn of the
|
||||
// new session.
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(switchReason, initialPrompt string) (bool, string) {
|
||||
if !k.Extensions().HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func(switchReason string) (bool, string) {
|
||||
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
|
||||
return func(switchReason, initialPrompt string) (bool, string) {
|
||||
return k.Extensions().EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,7 +1490,7 @@ type runModeDeps struct {
|
||||
getUIVisibility func() *ui.UIVisibility
|
||||
getStatusBarEntries func() []ui.StatusBarEntryData
|
||||
emitBeforeFork func(string, bool, string) (bool, string)
|
||||
emitBeforeSessionSwitch func(string) (bool, string)
|
||||
emitBeforeSessionSwitch func(string, string) (bool, string)
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
getExtensionCommands func() []commands.ExtensionCommand
|
||||
setModel func(string) error
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build ignore
|
||||
|
||||
// phase-handoff.go demonstrates ctx.NewSession by automating the multi-phase
|
||||
// workflow pattern: the agent works through a spec, writes a HANDOFF.md at
|
||||
// the end of each phase, then a fresh session picks up where the last one
|
||||
// left off.
|
||||
//
|
||||
// Two trigger modes are provided:
|
||||
//
|
||||
// 1. Automatic — when an assistant message ends with the sentinel
|
||||
// "<HANDOFF_READY>", the extension starts a new session and pre-loads
|
||||
// HANDOFF.md as the first prompt. Use this when you want the agent to
|
||||
// hand off control to itself with no user intervention.
|
||||
//
|
||||
// 2. Manual — the /handoff slash command starts a new session immediately
|
||||
// with the same handoff prompt. Useful when you finish a phase by hand
|
||||
// and want to clear the context window before the next one starts.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/phase-handoff.go
|
||||
//
|
||||
// Have your spec-driving agent write a HANDOFF.md at the end of each phase
|
||||
// and finish its message with the literal string `<HANDOFF_READY>`. The
|
||||
// next session boots automatically and reads HANDOFF.md as @file context.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// HANDOFFSentinel is the marker the agent appends to its last message to
|
||||
// request an automatic session switch. Change this to whatever fits your
|
||||
// workflow.
|
||||
const HANDOFFSentinel = "<HANDOFF_READY>"
|
||||
|
||||
// HANDOFFPrompt is the first prompt the new session receives. The leading
|
||||
// "@HANDOFF.md" triggers Kit's @file expansion, inlining the handoff file's
|
||||
// contents as XML-wrapped context.
|
||||
const HANDOFFPrompt = "Read @HANDOFF.md and continue with the next phase."
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Automatic trigger: detect the sentinel at the end of an agent turn.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
last := msgs[len(msgs)-1]
|
||||
if last.Role != "assistant" || !strings.Contains(last.Content, HANDOFFSentinel) {
|
||||
return
|
||||
}
|
||||
|
||||
// NewSession blocks while the agent finishes settling and then while
|
||||
// the TUI completes the switch; run it in a goroutine so the agent's
|
||||
// turn-end pipeline isn't stalled. The internal wait-for-idle (added
|
||||
// in response to issue #63) makes this reliable even when post-turn
|
||||
// tooling (formatters, on-save hooks, hidden tool calls) extends the
|
||||
// busy window past AgentEnd.
|
||||
go func() {
|
||||
if err := ctx.NewSession(HANDOFFPrompt); err != nil {
|
||||
ctx.PrintError("phase-handoff: " + err.Error())
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("phase-handoff: started a fresh session from HANDOFF.md")
|
||||
}()
|
||||
})
|
||||
|
||||
// Manual trigger: /handoff [optional override prompt]
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "handoff",
|
||||
Description: "Start a new session, optionally with a custom prompt",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
prompt := strings.TrimSpace(args)
|
||||
if prompt == "" {
|
||||
prompt = HANDOFFPrompt
|
||||
}
|
||||
if err := ctx.NewSession(prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Optional safeguard: surface the next prompt so the user can confirm
|
||||
// before the auto-handoff proceeds. Set kit option "handoff.confirm=1"
|
||||
// to enable.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if ctx.GetOption("handoff.confirm") != "1" {
|
||||
return nil
|
||||
}
|
||||
if e.InitialPrompt == "" {
|
||||
return nil
|
||||
}
|
||||
resp := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Start a new session with prompt:\n " + e.InitialPrompt + "\n\nProceed?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if resp.Cancelled || !resp.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "handoff cancelled by user",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -5,16 +5,16 @@ go 1.26.4
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/fantasy v0.31.0
|
||||
charm.land/fantasy v0.32.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1
|
||||
github.com/charmbracelet/x/editor v0.2.0
|
||||
github.com/clipperhouse/displaywidth v0.11.0
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/indaco/herald v0.13.0
|
||||
github.com/indaco/herald-md v0.3.0
|
||||
github.com/mark3labs/mcp-go v0.54.1
|
||||
github.com/mark3labs/mcp-go v0.55.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
@@ -85,13 +85,13 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.26 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.8.0 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.8.1 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.3.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -116,8 +116,8 @@ require (
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.284.0 // indirect
|
||||
google.golang.org/genai v1.60.0 // indirect
|
||||
google.golang.org/api v0.285.0 // indirect
|
||||
google.golang.org/genai v1.61.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/fantasy v0.31.0 h1:ioLVRi7A8lZXR8mrCIeseuCcq0KqAak46revmGumnpc=
|
||||
charm.land/fantasy v0.31.0/go.mod h1:lAE2gO68SrB1S5TrW5g0TRoxz9V+qJcg0Elx/uPWsDI=
|
||||
charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
|
||||
charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
@@ -28,8 +28,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -84,8 +84,8 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be h1:pg+OWlIkk9HOe/8P5J95aKe2wGDzFUiiyFOUpwR30B4=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
@@ -191,8 +191,8 @@ github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8
|
||||
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
|
||||
github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
|
||||
github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
|
||||
github.com/kaptinlin/jsonschema v0.8.0 h1:GhY966O2q3ZQsg1zkQj988KF2MADJ6EA7pKBMpGmb9A=
|
||||
github.com/kaptinlin/jsonschema v0.8.0/go.mod h1:dxt7s98W5NEuWEwCnAwGrhYGQdaRLqXZImR28DuxcMU=
|
||||
github.com/kaptinlin/jsonschema v0.8.1 h1:Krhuq1HpE+olHoPfcxkohqKKCnXfixUPv+aUYRegBBQ=
|
||||
github.com/kaptinlin/jsonschema v0.8.1/go.mod h1:mCH2W5lXd29tdDjvoFfY32nedPORnlk7pCVrrcs/NkQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -201,8 +201,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
||||
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
@@ -221,8 +221,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
|
||||
github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
@@ -312,14 +312,14 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc=
|
||||
google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado=
|
||||
google.golang.org/genai v1.60.0 h1:uAkea4tYhCz1LlUmxdiOFAmlrLFaLs8PbXucgZHqHVo=
|
||||
google.golang.org/genai v1.60.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM=
|
||||
google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
|
||||
google.golang.org/genai v1.61.0 h1:wCyNGiaC9q5A59B80zuEtNBhq3ypEvICFkZYOfK7IO0=
|
||||
google.golang.org/genai v1.61.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad h1:cYL1DPJAQr4JMvhfGao0PDXoaf03ifMljAuDyrbMBd0=
|
||||
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:cVHIikDNAdx8ISZeW+2rYkEMf3xn0GSaBYmVnWXQBUo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
|
||||
@@ -96,6 +96,9 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
ec.SendMessage = func(string) {}
|
||||
ec.CancelAndSend = func(string) {}
|
||||
ec.NewSession = func(string) error {
|
||||
return fmt.Errorf("new session not available in ACP mode")
|
||||
}
|
||||
ec.Exit = func() {}
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
|
||||
+168
-9
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -24,6 +25,26 @@ type queueItem struct {
|
||||
Files []kit.LLMFilePart
|
||||
}
|
||||
|
||||
// ErrAgentBusy is returned when an operation cannot proceed because the agent
|
||||
// is still processing a turn (including any post-turn extension hooks) and did
|
||||
// not become idle before the operation's deadline.
|
||||
//
|
||||
// This is an alias for extensions.ErrAgentBusy so the extension API and the
|
||||
// app layer share a single sentinel value — callers can detect the condition
|
||||
// with errors.Is(err, app.ErrAgentBusy) without substring-matching the error
|
||||
// message.
|
||||
var ErrAgentBusy = extensions.ErrAgentBusy
|
||||
|
||||
// DefaultNewSessionIdleWait bounds how long RequestNewSessionFromExtension
|
||||
// will block waiting for the agent to settle. It needs to be generous enough
|
||||
// to cover real-world post-turn tooling (project formatters, on-save linters,
|
||||
// hidden tool calls) which routinely hold the busy flag for seconds and
|
||||
// occasionally minutes — yet still short enough to surface a wedged agent.
|
||||
//
|
||||
// Issue #63 reported workloads where the busy window regularly exceeded
|
||||
// 6 seconds; ten minutes is the same bound the workaround in that issue used.
|
||||
const DefaultNewSessionIdleWait = 10 * time.Minute
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
// conversation history (via MessageStore), and queue management. It is
|
||||
// designed to be created once per session and reused across multiple prompts.
|
||||
@@ -55,11 +76,25 @@ type App struct {
|
||||
// each new step and called by CancelCurrentStep().
|
||||
cancelStep context.CancelFunc
|
||||
|
||||
// mu protects busy, queue, and cancelStep.
|
||||
// mu protects busy, queue, cancelStep, and idleCh.
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
queue []queueItem
|
||||
|
||||
// idleCh is closed when the agent transitions from busy back to idle.
|
||||
// While the agent is idle the channel is already closed (recv returns
|
||||
// immediately). When busy transitions to true a fresh open channel is
|
||||
// allocated so callers blocked on the previous one are released. All
|
||||
// transitions are funnelled through setBusyLocked to keep the channel
|
||||
// pointer in sync with the busy flag.
|
||||
//
|
||||
// This is the underlying primitive WaitForIdle and
|
||||
// RequestNewSessionFromExtension wait on to fix the AgentEnd→NewSession
|
||||
// race described in issue #63: AgentEnd is emitted from inside the agent
|
||||
// loop, before drainQueue clears busy, so any extension hook that calls
|
||||
// ctx.NewSession synchronously would otherwise observe busy==true.
|
||||
idleCh chan struct{}
|
||||
|
||||
// wg tracks in-flight goroutines; Close() waits on it.
|
||||
wg sync.WaitGroup
|
||||
|
||||
@@ -95,6 +130,10 @@ type App struct {
|
||||
// initialMessages may be nil or empty for a fresh session.
|
||||
func New(opts Options, initialMessages []kit.LLMMessage) *App {
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
// idleCh starts already closed: the freshly constructed App is idle, so
|
||||
// any caller blocking on it via WaitForIdle should be released immediately.
|
||||
idleCh := make(chan struct{})
|
||||
close(idleCh)
|
||||
return &App{
|
||||
opts: opts,
|
||||
store: NewMessageStoreWithMessages(initialMessages),
|
||||
@@ -102,6 +141,90 @@ func New(opts Options, initialMessages []kit.LLMMessage) *App {
|
||||
rootCancel: rootCancel,
|
||||
// cancelStep starts as a no-op so CancelCurrentStep() is always safe.
|
||||
cancelStep: func() {},
|
||||
idleCh: idleCh,
|
||||
}
|
||||
}
|
||||
|
||||
// setBusyLocked is the single chokepoint for mutating a.busy. It keeps the
|
||||
// idleCh signalling channel in sync with the busy flag:
|
||||
//
|
||||
// - false → true: allocate a fresh open channel so future WaitForIdle
|
||||
// callers block until the next idle transition.
|
||||
// - true → false: close the current channel so any waiters wake up.
|
||||
//
|
||||
// No-op when the requested state already matches. The caller must hold a.mu.
|
||||
func (a *App) setBusyLocked(busy bool) {
|
||||
if a.busy == busy {
|
||||
return
|
||||
}
|
||||
a.busy = busy
|
||||
if busy {
|
||||
a.idleCh = make(chan struct{})
|
||||
} else {
|
||||
close(a.idleCh)
|
||||
}
|
||||
}
|
||||
|
||||
// idleSnapshot returns the current busy state and the channel that will be
|
||||
// closed on the next idle transition. The snapshot is taken under a.mu so the
|
||||
// pair is consistent (busy==true ⇒ ch is the open channel for *this* busy
|
||||
// cycle, not a stale one).
|
||||
func (a *App) idleSnapshot() (busy bool, ch chan struct{}) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.busy, a.idleCh
|
||||
}
|
||||
|
||||
// WaitForIdle blocks until the agent is idle, the given timeout elapses, or
|
||||
// the app shuts down. Returns nil on idle, ErrAgentBusy on timeout, or the
|
||||
// rootCtx error if the app is closing.
|
||||
//
|
||||
// A non-positive timeout disables the deadline and waits indefinitely (until
|
||||
// idle or app shutdown). Safe to call from any goroutine, but never from
|
||||
// inside the Bubble Tea Update() loop — it blocks.
|
||||
//
|
||||
// Idiomatic use from extensions:
|
||||
//
|
||||
// if err := app.WaitForIdle(0); err != nil { /* shutdown */ }
|
||||
//
|
||||
// The loop guards against the agent re-arming itself between wakeups: if
|
||||
// another prompt is queued (or a steer message lands) while we're waiting,
|
||||
// setBusyLocked allocates a fresh idleCh and we wait again.
|
||||
func (a *App) WaitForIdle(timeout time.Duration) error {
|
||||
var deadline time.Time
|
||||
if timeout > 0 {
|
||||
deadline = time.Now().Add(timeout)
|
||||
}
|
||||
for {
|
||||
busy, ch := a.idleSnapshot()
|
||||
if !busy {
|
||||
return nil
|
||||
}
|
||||
var timer *time.Timer
|
||||
var timerCh <-chan time.Time
|
||||
if timeout > 0 {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return ErrAgentBusy
|
||||
}
|
||||
timer = time.NewTimer(remaining)
|
||||
timerCh = timer.C
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
// Idle transition observed — loop and re-check under the
|
||||
// mutex in case a new busy cycle started immediately after.
|
||||
case <-timerCh:
|
||||
return ErrAgentBusy
|
||||
case <-a.rootCtx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return a.rootCtx.Err()
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +278,7 @@ func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
|
||||
return qLen
|
||||
}
|
||||
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -235,7 +358,7 @@ func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as RunWithFiles().
|
||||
item := queueItem{Prompt: prompt, Files: files}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -271,7 +394,7 @@ func (a *App) InterruptAndSend(prompt string) {
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
@@ -470,7 +593,7 @@ func (a *App) CompactConversation(customInstructions string) error {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("SDK instance not available")
|
||||
}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -532,7 +655,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("SDK instance not available")
|
||||
}
|
||||
a.busy = true
|
||||
a.setBusyLocked(true)
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
|
||||
@@ -621,7 +744,7 @@ func (a *App) releaseBusyAfterCompact() {
|
||||
// in just before closed was set.
|
||||
if a.closed {
|
||||
a.queue = a.queue[:0]
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -633,7 +756,7 @@ func (a *App) releaseBusyAfterCompact() {
|
||||
a.queue = a.queue[:0]
|
||||
|
||||
if len(pending) == 0 {
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -850,7 +973,7 @@ func (a *App) drainQueue(first queueItem) {
|
||||
|
||||
// Mark as no longer busy
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.setBusyLocked(false)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1230,6 +1353,42 @@ func (a *App) SetEditorTextFromExtension(text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestNewSessionFromExtension sends a NewSessionRequestEvent to the TUI
|
||||
// to end the current session and start a fresh one. If initialPrompt is
|
||||
// non-empty it is submitted as the first user turn of the new session.
|
||||
//
|
||||
// If the agent is currently busy (e.g. the caller is an OnAgentEnd hook that
|
||||
// fires before drainQueue clears the busy flag, or there are queued prompts
|
||||
// still being processed) the call blocks until the agent becomes idle, up to
|
||||
// DefaultNewSessionIdleWait. If that deadline elapses, ErrAgentBusy is
|
||||
// returned and callers can detect it with errors.Is. This wait-then-send
|
||||
// behavior fixes the v0.79.0 phase-handoff race documented in issue #63.
|
||||
//
|
||||
// Returns an error when running headless (no TUI attached), when the wait
|
||||
// for idle times out (ErrAgentBusy), when the app is shutting down, or when
|
||||
// a BeforeSessionSwitch extension hook cancels the switch.
|
||||
//
|
||||
// This is the implementation behind ctx.NewSession(prompt) for the
|
||||
// interactive TUI. It blocks the caller until the TUI processes the
|
||||
// switch, so it must be invoked from a goroutine outside Update().
|
||||
func (a *App) RequestNewSessionFromExtension(initialPrompt string) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
return fmt.Errorf("new session unavailable: no interactive TUI attached")
|
||||
}
|
||||
if err := a.WaitForIdle(DefaultNewSessionIdleWait); err != nil {
|
||||
if errors.Is(err, ErrAgentBusy) {
|
||||
return fmt.Errorf("cannot start new session: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
ch := make(chan error, 1)
|
||||
prog.Send(NewSessionRequestEvent{InitialPrompt: initialPrompt, ResponseCh: ch})
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
|
||||
+283
-5
@@ -794,7 +794,7 @@ func TestReleaseBusyAfterCompact_flushesQueuedMessages(t *testing.T) {
|
||||
// summarising. (Run() would have appended them and returned a queue
|
||||
// length > 0 to the caller.)
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued during compact #1"},
|
||||
queueItem{Prompt: "queued during compact #2"},
|
||||
@@ -834,7 +834,7 @@ func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) {
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.mu.Unlock()
|
||||
|
||||
app.releaseBusyAfterCompact()
|
||||
@@ -901,7 +901,7 @@ func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) {
|
||||
// Simulate the state at the end of compaction: busy is set and a couple
|
||||
// of regular Run() prompts have piled up after the steer messages.
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued-1"},
|
||||
queueItem{Prompt: "queued-2"},
|
||||
@@ -950,7 +950,7 @@ func TestReleaseBusyAfterCompact_dropsQueueWhenClosed(t *testing.T) {
|
||||
app := newTestApp(stub)
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.queue = append(app.queue, queueItem{Prompt: "would have run"})
|
||||
app.closed = true
|
||||
app.mu.Unlock()
|
||||
@@ -999,7 +999,7 @@ func TestPopLastUserMessage_WhileBusy(t *testing.T) {
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.setBusyLocked(true)
|
||||
app.mu.Unlock()
|
||||
|
||||
_, _, err := app.PopLastUserMessage()
|
||||
@@ -1115,3 +1115,281 @@ func TestPopLastUserMessage_NoUserOnBranch(t *testing.T) {
|
||||
t.Fatalf("expected error mentioning missing user message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// WaitForIdle / RequestNewSessionFromExtension (issue #63)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestWaitForIdle_AlreadyIdle verifies the fast path: a freshly constructed
|
||||
// App is idle and WaitForIdle returns immediately without consulting the
|
||||
// timeout.
|
||||
func TestWaitForIdle_AlreadyIdle(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
start := time.Now()
|
||||
if err := app.WaitForIdle(2 * time.Second); err != nil {
|
||||
t.Fatalf("WaitForIdle on idle app: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("WaitForIdle blocked for %s on already-idle app", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_BlocksUntilDrain reproduces the issue #63 race: while
|
||||
// drainQueue holds busy==true the call should block, then return nil as soon
|
||||
// as the drain completes.
|
||||
func TestWaitForIdle_BlocksUntilDrain(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
|
||||
// Confirm the agent is busy before we start waiting.
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.WaitForIdle(5 * time.Second)
|
||||
}()
|
||||
|
||||
// Should not return while the stub is blocked.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("WaitForIdle returned early (err=%v) while agent still busy", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle did not return after drain completed")
|
||||
}
|
||||
|
||||
if app.IsBusy() {
|
||||
t.Fatal("app still reports busy after WaitForIdle returned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_TimeoutReturnsErrAgentBusy verifies that a slow turn yields
|
||||
// ErrAgentBusy (detectable via errors.Is) when the deadline elapses.
|
||||
func TestWaitForIdle_TimeoutReturnsErrAgentBusy(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
// Release the stub before Close so wg.Wait() can return.
|
||||
t.Cleanup(func() {
|
||||
close(gate)
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
err := app.WaitForIdle(50 * time.Millisecond)
|
||||
if !errors.Is(err, ErrAgentBusy) {
|
||||
t.Fatalf("expected ErrAgentBusy on timeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_ZeroTimeoutWaitsIndefinitely verifies that a non-positive
|
||||
// timeout still blocks until idle (or shutdown) — not an instant ErrAgentBusy.
|
||||
func TestWaitForIdle_ZeroTimeoutWaitsIndefinitely(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app.Close()
|
||||
})
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.WaitForIdle(0) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("WaitForIdle(0) returned early with %v while agent was busy", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForIdle(0) returned %v after idle", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle(0) did not return after drain completed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_AppClose verifies that shutting down the app while a
|
||||
// caller is blocked in WaitForIdle releases the wait.
|
||||
func TestWaitForIdle_AppClose(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("done"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
|
||||
app.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
|
||||
t.Fatal("app never became busy after Run()")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.WaitForIdle(5 * time.Second) }()
|
||||
|
||||
// Give the goroutine a moment to enter the wait.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// rootCancel is called by Close, which should release the waiter
|
||||
// before drainQueue itself observes the cancellation and clears busy.
|
||||
go func() {
|
||||
// Unblock the stub so Close() can proceed past wg.Wait().
|
||||
close(gate)
|
||||
}()
|
||||
app.Close()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
// Either rootCtx cancellation propagated first (err = context.Canceled)
|
||||
// or the drain finished cleanly first (err == nil); both are
|
||||
// acceptable terminations. The key invariant is that WaitForIdle
|
||||
// does not hang past Close.
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("WaitForIdle returned unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("WaitForIdle did not return after Close()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestNewSessionFromExtension_NoTUI verifies the headless guard: with
|
||||
// no Bubble Tea program registered the call fails fast (no busy-wait).
|
||||
func TestRequestNewSessionFromExtension_NoTUI(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
err := app.RequestNewSessionFromExtension("hello")
|
||||
if err == nil {
|
||||
t.Fatal("expected error in headless mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no interactive TUI") {
|
||||
t.Fatalf("expected 'no interactive TUI' error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBusyTransitionsSignalIdleCh exercises the setBusyLocked invariants
|
||||
// directly: a fresh App is idle (closed channel); Run() opens a new channel
|
||||
// that is then closed when drainQueue exits.
|
||||
func TestBusyTransitionsSignalIdleCh(t *testing.T) {
|
||||
app := newTestApp(newStub("ok"))
|
||||
defer app.Close()
|
||||
|
||||
// Initial state: closed channel, busy==false.
|
||||
busy, ch := app.idleSnapshot()
|
||||
if busy {
|
||||
t.Fatal("freshly constructed App should not be busy")
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
t.Fatal("initial idleCh should already be closed")
|
||||
}
|
||||
|
||||
gate := make(chan struct{})
|
||||
var gateOnce sync.Once
|
||||
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
|
||||
stub := newStubWithFuncs(func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return turnResult("ok"), nil
|
||||
})
|
||||
app2 := newTestApp(stub)
|
||||
t.Cleanup(func() {
|
||||
closeGate()
|
||||
app2.Close()
|
||||
})
|
||||
|
||||
app2.Run("hello")
|
||||
if !waitForCondition(2*time.Second, func() bool { return app2.IsBusy() }) {
|
||||
t.Fatal("app2 never became busy")
|
||||
}
|
||||
|
||||
_, ch2 := app2.idleSnapshot()
|
||||
select {
|
||||
case <-ch2:
|
||||
t.Fatal("idleCh should be open while busy")
|
||||
default:
|
||||
}
|
||||
|
||||
closeGate()
|
||||
|
||||
select {
|
||||
case <-ch2:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("idleCh was never closed after drain completed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,21 @@ type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// NewSessionRequestEvent is sent when an extension calls ctx.NewSession to
|
||||
// end the current session and start a fresh one. The TUI routes this into
|
||||
// the same /new code path (including the BeforeSessionSwitch hook and any
|
||||
// @file expansion in InitialPrompt). ResponseCh, when non-nil, receives a
|
||||
// single result so the extension goroutine can observe success or failure.
|
||||
type NewSessionRequestEvent struct {
|
||||
// InitialPrompt, when non-empty, is the first user turn to submit
|
||||
// after the session switch. @file references are expanded.
|
||||
InitialPrompt string
|
||||
// ResponseCh receives the outcome (nil error on success). Must be
|
||||
// buffered (cap >= 1) so the TUI never blocks. May be nil if the
|
||||
// caller does not need the result.
|
||||
ResponseCh chan<- error
|
||||
}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
|
||||
+11
-10
@@ -227,16 +227,17 @@ type GenerationParams struct {
|
||||
// or other custom/ prefixed models. These models are loaded from the config file
|
||||
// and merged into the custom provider in the model registry.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
|
||||
// Generation parameter defaults for this model.
|
||||
// These are applied when the user hasn't explicitly set the corresponding
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrAgentBusy is returned (wrapped) when an extension API call that requires
|
||||
// the agent to be idle cannot proceed because the agent is still processing a
|
||||
// turn or post-turn hooks. Most notably, ctx.NewSession waits for idle
|
||||
// internally; if its wait deadline elapses it returns an error that wraps
|
||||
// this sentinel.
|
||||
//
|
||||
// Extensions can detect the condition with errors.Is:
|
||||
//
|
||||
// if err := ctx.NewSession(prompt); err != nil {
|
||||
// if errors.Is(err, ext.ErrAgentBusy) {
|
||||
// // agent never settled — fall back to a queued message instead
|
||||
// }
|
||||
// }
|
||||
var ErrAgentBusy = errors.New("agent is busy")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types (used by runner, NOT exposed to Yaegi)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -124,6 +143,48 @@ type Context struct {
|
||||
// })
|
||||
SendMultimodalMessage func(text string, files []FilePart)
|
||||
|
||||
// NewSession ends the current session and starts a fresh one (matching
|
||||
// the /new slash command). When prompt is non-empty it is submitted as
|
||||
// the first user turn of the new session, with @file references
|
||||
// expanded the same way they are for normal user input. Pass an empty
|
||||
// string to start an empty session.
|
||||
//
|
||||
// If the agent is currently busy when NewSession is called (for example,
|
||||
// from an OnAgentEnd hook that fires before the agent fully settles, or
|
||||
// while post-turn formatters/linters are still running), the call blocks
|
||||
// until the agent transitions to idle. This avoids the v0.79.0
|
||||
// phase-handoff race where NewSession from OnAgentEnd would fail with
|
||||
// "agent is busy" because TurnEnd fires before the busy flag clears.
|
||||
// The wait has a generous internal timeout; if it elapses the returned
|
||||
// error wraps ErrAgentBusy (detectable with errors.Is).
|
||||
//
|
||||
// Returns an error if the agent does not become idle within the wait
|
||||
// window, if a registered BeforeSessionSwitch handler cancels the
|
||||
// switch, or if the new session file cannot be created. In
|
||||
// non-interactive (ACP / headless) mode this is a no-op that returns
|
||||
// an error.
|
||||
//
|
||||
// Because NewSession may block, call it from a goroutine — not
|
||||
// directly from inside an event handler that the agent loop is waiting
|
||||
// on.
|
||||
//
|
||||
// Typical pattern — start a fresh session at the end of a phase by
|
||||
// reading a handoff file:
|
||||
//
|
||||
// api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// msgs := ctx.GetMessages()
|
||||
// if len(msgs) == 0 {
|
||||
// return
|
||||
// }
|
||||
// last := msgs[len(msgs)-1].Content
|
||||
// if strings.Contains(last, "<HANDOFF_READY>") {
|
||||
// go func() {
|
||||
// _ = ctx.NewSession("Read @HANDOFF.md and continue the next phase.")
|
||||
// }()
|
||||
// }
|
||||
// })
|
||||
NewSession func(prompt string) error
|
||||
|
||||
// GetSessionUsage returns aggregated token usage and cost statistics
|
||||
// for the current session. This includes total input/output tokens,
|
||||
// cache read/write tokens, total cost, and request count.
|
||||
@@ -2296,6 +2357,12 @@ type BeforeSessionSwitchEvent struct {
|
||||
// Reason describes why the switch is happening: "new" for /new command,
|
||||
// "clear" for /clear command.
|
||||
Reason string
|
||||
// InitialPrompt, when non-empty, is the prompt that will be submitted
|
||||
// as the first user turn of the new session. Set when /new is invoked
|
||||
// with an argument (e.g. "/new continue from HANDOFF.md") or when an
|
||||
// extension calls ctx.NewSession(prompt). Extensions may inspect this
|
||||
// to decide whether to allow the switch.
|
||||
InitialPrompt string
|
||||
}
|
||||
|
||||
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
|
||||
|
||||
@@ -192,6 +192,9 @@ func normalizeContext(ctx Context) Context {
|
||||
if ctx.SendMultimodalMessage == nil {
|
||||
ctx.SendMultimodalMessage = func(string, []FilePart) {}
|
||||
}
|
||||
if ctx.NewSession == nil {
|
||||
ctx.NewSession = func(string) error { return fmt.Errorf("new session not available") }
|
||||
}
|
||||
if ctx.GetSessionUsage == nil {
|
||||
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ func Symbols() interp.Exports {
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Sentinel errors. Extensions detect them with errors.Is:
|
||||
//
|
||||
// if errors.Is(err, ext.ErrAgentBusy) { ... }
|
||||
"ErrAgentBusy": reflect.ValueOf(&ErrAgentBusy).Elem(),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
@@ -53,6 +53,11 @@ type AgentSetupOptions struct {
|
||||
// Debug enables debug logging. When zero-value, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
Debug bool
|
||||
// DebugLogger, if non-nil, is used directly as the engine/MCP debug
|
||||
// logger — overriding the built-in SimpleDebugLogger / BufferedDebugLogger
|
||||
// selected by Debug + UseBufferedLogger. Callers supply this when they
|
||||
// want to route debug output into their own logging system.
|
||||
DebugLogger tools.DebugLogger
|
||||
// NoExtensions skips extension loading. When false, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
NoExtensions bool
|
||||
@@ -192,7 +197,12 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
// Create the appropriate debug logger.
|
||||
var debugLogger tools.DebugLogger
|
||||
var bufferedLogger *tools.BufferedDebugLogger
|
||||
if debugEnabled {
|
||||
switch {
|
||||
case opts.DebugLogger != nil:
|
||||
// Caller-supplied logger wins unconditionally. Its IsDebugEnabled()
|
||||
// is the source of truth for whether downstream code emits messages.
|
||||
debugLogger = opts.DebugLogger
|
||||
case debugEnabled:
|
||||
if opts.UseBufferedLogger {
|
||||
bufferedLogger = tools.NewBufferedDebugLogger(true)
|
||||
debugLogger = bufferedLogger
|
||||
|
||||
+20
-18
@@ -44,13 +44,14 @@ func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
|
||||
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
|
||||
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
|
||||
info := ModelInfo{
|
||||
ID: modelID,
|
||||
Name: cfg.Name,
|
||||
Attachment: cfg.Attachment,
|
||||
Reasoning: cfg.Reasoning,
|
||||
Temperature: cfg.Temperature,
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
ID: modelID,
|
||||
Name: cfg.Name,
|
||||
Attachment: cfg.Attachment,
|
||||
Reasoning: cfg.Reasoning,
|
||||
Temperature: cfg.Temperature,
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
APIModelName: cfg.APIModelName,
|
||||
Cost: Cost{
|
||||
Input: cfg.Cost.Input,
|
||||
Output: cfg.Cost.Output,
|
||||
@@ -287,17 +288,18 @@ type GenerationParams struct {
|
||||
// CustomModelConfig defines a custom model configuration loaded from the config file.
|
||||
// This is a duplicate here to avoid circular dependencies with internal/config.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
|
||||
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1533,7 +1533,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return nil, wrapProviderErr("custom", "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
apiModelName := modelName
|
||||
if modelInfo != nil && modelInfo.APIModelName != "" {
|
||||
apiModelName = modelInfo.APIModelName
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, apiModelName)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("custom", "model", err)
|
||||
}
|
||||
|
||||
+12
-11
@@ -16,17 +16,18 @@ var embeddedModelsJSON []byte
|
||||
|
||||
// ModelInfo represents information about a specific model.
|
||||
type ModelInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Family string // Model family (e.g., "claude", "gpt", "gemini")
|
||||
Attachment bool
|
||||
Reasoning bool
|
||||
Temperature bool
|
||||
Cost Cost
|
||||
Limit Limit
|
||||
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
|
||||
BaseURL string // Per-model base URL override (custom models only)
|
||||
APIKey string // Per-model API key override (custom models only)
|
||||
ID string
|
||||
Name string
|
||||
Family string // Model family (e.g., "claude", "gpt", "gemini")
|
||||
Attachment bool
|
||||
Reasoning bool
|
||||
Temperature bool
|
||||
Cost Cost
|
||||
Limit Limit
|
||||
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
|
||||
BaseURL string // Per-model base URL override (custom models only)
|
||||
APIKey string // Per-model API key override (custom models only)
|
||||
APIModelName string // Per-model API model name override (custom models only)
|
||||
|
||||
// Params holds per-model generation parameter defaults. These are applied
|
||||
// when the user hasn't explicitly set the corresponding CLI flag or global
|
||||
|
||||
@@ -146,9 +146,10 @@ var SlashCommands = []SlashCommand{
|
||||
},
|
||||
{
|
||||
Name: "/new",
|
||||
Description: "Start a new session",
|
||||
Description: "Start a new session (optionally with an initial prompt)",
|
||||
Category: "Navigation",
|
||||
Aliases: []string{"/n"},
|
||||
HasArgs: true,
|
||||
},
|
||||
{
|
||||
Name: "/name",
|
||||
|
||||
+119
-17
@@ -445,9 +445,12 @@ type AppModelOptions struct {
|
||||
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// EmitBeforeSessionSwitch, if non-nil, is called before switching
|
||||
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
|
||||
// reason). May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
// to a new session branch (e.g. /new, /clear). reason is the trigger
|
||||
// ("new", "clear", "extension"); initialPrompt is the user prompt
|
||||
// that will run as the first turn of the new session (empty when
|
||||
// /new is called without arguments). Returns (cancelled, reason).
|
||||
// May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
|
||||
|
||||
// GetGlobalShortcuts, if non-nil, returns extension-registered global
|
||||
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
|
||||
@@ -575,6 +578,13 @@ type AppModel struct {
|
||||
// flushed first, preserving chronological order.
|
||||
pendingUserPrints []string
|
||||
|
||||
// newSessionResultCh, when non-nil, receives the outcome of an
|
||||
// in-flight extension-triggered NewSession request. Set when an
|
||||
// app.NewSessionRequestEvent arrives; cleared (with a result sent)
|
||||
// in performNewSession success/failure paths or in the
|
||||
// beforeSessionSwitchResultMsg cancellation path.
|
||||
newSessionResultCh chan<- error
|
||||
|
||||
// canceling tracks whether the user has pressed ESC once during stateWorking.
|
||||
// A second ESC within 2 seconds will cancel the current step.
|
||||
canceling bool
|
||||
@@ -677,7 +687,7 @@ type AppModel struct {
|
||||
|
||||
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
|
||||
// Returns (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
emitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
|
||||
|
||||
// thinkingLevel is the current extended thinking level.
|
||||
thinkingLevel string
|
||||
@@ -2192,6 +2202,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
|
||||
case app.NewSessionRequestEvent:
|
||||
// Extension wants to end the current session and start a fresh
|
||||
// one (with an optional initial prompt). Stash the response
|
||||
// channel so performNewSession (or the before-hook cancellation
|
||||
// path) can signal completion, then run the same /new pipeline
|
||||
// the user would trigger.
|
||||
if msg.ResponseCh != nil {
|
||||
// Only one new-session request in flight at a time. If a
|
||||
// previous response channel is still pending, fail it before
|
||||
// replacing it so the prior extension goroutine unblocks.
|
||||
if m.newSessionResultCh != nil {
|
||||
m.newSessionResultCh <- fmt.Errorf("superseded by a newer NewSession request")
|
||||
}
|
||||
m.newSessionResultCh = msg.ResponseCh
|
||||
}
|
||||
if cmd := m.handleNewCommand(msg.InitialPrompt); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.PasswordPromptEvent:
|
||||
// Sudo password prompt - show a modal input prompt
|
||||
// If already in prompt state, cancel the new request
|
||||
@@ -2397,8 +2426,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// session reset if the hook did not cancel.
|
||||
if msg.cancelled {
|
||||
m.printSystemMessage(msg.reason)
|
||||
m.signalNewSessionResult(fmt.Errorf("session switch cancelled: %s", msg.reason))
|
||||
} else {
|
||||
cmds = append(cmds, m.performNewSession())
|
||||
cmds = append(cmds, m.performNewSession(msg.initialPrompt))
|
||||
}
|
||||
|
||||
case beforeForkResultMsg:
|
||||
@@ -3241,7 +3271,7 @@ func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) te
|
||||
case "/fork":
|
||||
return m.handleForkCommand()
|
||||
case "/new":
|
||||
return m.handleNewCommand()
|
||||
return m.handleNewCommand(args)
|
||||
case "/name":
|
||||
return m.handleNameCommand(args)
|
||||
case "/resume":
|
||||
@@ -3672,7 +3702,7 @@ func (m *AppModel) printHelpMessage() {
|
||||
"**Navigation:**\n" +
|
||||
"- `/tree`: Navigate session tree (switch branches)\n" +
|
||||
"- `/fork`: Branch from an earlier message\n" +
|
||||
"- `/new`: Start a new session (discards context, saves old session)\n" +
|
||||
"- `/new [prompt]`: Start a new session (discards context, saves old session). With a prompt, runs it as the first message; supports `@file` attachments.\n" +
|
||||
"- `/resume`: Open session picker to switch sessions\n" +
|
||||
"- `/name <name>`: Set a display name for this session\n\n" +
|
||||
"**System:**\n" +
|
||||
@@ -4368,7 +4398,12 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
|
||||
|
||||
// handleNewCommand starts a completely new session (Pi-style /new behavior).
|
||||
// Creates a new session file, discarding all context from the previous conversation.
|
||||
func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
// If initialPrompt is non-empty it is submitted as the first user turn of the
|
||||
// new session, with @file references expanded the same way they are for
|
||||
// regular user input.
|
||||
func (m *AppModel) handleNewCommand(initialPrompt string) tea.Cmd {
|
||||
initialPrompt = strings.TrimSpace(initialPrompt)
|
||||
|
||||
// Emit before-session-switch event in a goroutine so that extension
|
||||
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
|
||||
// deadlocking the BubbleTea event loop.
|
||||
@@ -4376,23 +4411,25 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
emit := m.emitBeforeSessionSwitch
|
||||
ctrl := m.appCtrl
|
||||
go func() {
|
||||
cancelled, reason := emit("new")
|
||||
cancelled, reason := emit("new", initialPrompt)
|
||||
ctrl.SendEvent(beforeSessionSwitchResultMsg{
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
initialPrompt: initialPrompt,
|
||||
})
|
||||
}()
|
||||
return noopCmd
|
||||
}
|
||||
|
||||
return m.performNewSession()
|
||||
return m.performNewSession(initialPrompt)
|
||||
}
|
||||
|
||||
// performNewSession performs the actual session reset. Called either directly
|
||||
// (when no before-hook exists) or after the async hook completes.
|
||||
// Matches Pi behavior: creates a completely new session file, discarding all
|
||||
// context from the previous conversation.
|
||||
func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// context from the previous conversation. If initialPrompt is non-empty it
|
||||
// is submitted as the first user turn (with @file expansion).
|
||||
func (m *AppModel) performNewSession(initialPrompt string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
// No tree session — just clear messages.
|
||||
@@ -4406,13 +4443,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// Clear the ScrollList so the new session starts fresh.
|
||||
m.messages = []MessageItem{}
|
||||
m.printSystemMessage("Conversation cleared. Starting fresh.")
|
||||
return nil
|
||||
cmd := m.submitInitialPrompt(initialPrompt)
|
||||
m.signalNewSessionResult(nil)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Create a brand new session file (Pi-style /new behavior)
|
||||
newTs, err := session.CreateTreeSession(m.cwd)
|
||||
if err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to create new session: %v", err))
|
||||
m.signalNewSessionResult(fmt.Errorf("create new session: %w", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4425,6 +4465,67 @@ func (m *AppModel) performNewSession() tea.Cmd {
|
||||
// Clear the ScrollList so the new session starts fresh.
|
||||
m.messages = []MessageItem{}
|
||||
m.printSystemMessage("New session started. Previous conversation saved.")
|
||||
cmd := m.submitInitialPrompt(initialPrompt)
|
||||
m.signalNewSessionResult(nil)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// signalNewSessionResult delivers the outcome of an extension-triggered
|
||||
// NewSession request (if one is in flight) and clears the response channel.
|
||||
// Safe to call when no request is pending.
|
||||
func (m *AppModel) signalNewSessionResult(err error) {
|
||||
if m.newSessionResultCh == nil {
|
||||
return
|
||||
}
|
||||
ch := m.newSessionResultCh
|
||||
m.newSessionResultCh = nil
|
||||
// Channel is buffered (cap >= 1) by contract — send is non-blocking.
|
||||
ch <- err
|
||||
}
|
||||
|
||||
// submitInitialPrompt is the shared submission path used by /new <prompt>
|
||||
// and ctx.NewSession(prompt). It mirrors the SubmitMsg handler: @file
|
||||
// references are expanded via fileutil.ProcessFileAttachments and the
|
||||
// resulting prompt is forwarded to AppController.Run / RunWithFiles.
|
||||
// Returns nil when prompt is empty.
|
||||
func (m *AppModel) submitInitialPrompt(prompt string) tea.Cmd {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" || m.appCtrl == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
processedText := prompt
|
||||
var fileParts []kit.LLMFilePart
|
||||
if m.cwd != "" {
|
||||
result := fileutil.ProcessFileAttachments(prompt, m.cwd, m.mcpResourceReader)
|
||||
processedText = result.ProcessedText
|
||||
for _, fp := range result.FileParts {
|
||||
fileParts = append(fileParts, kit.LLMFilePart{
|
||||
Filename: fp.Filename,
|
||||
Data: fp.Data,
|
||||
MediaType: fp.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
displayText := prompt
|
||||
if len(fileParts) > 0 {
|
||||
displayText = fmt.Sprintf("%s\n[%d file(s) attached]", prompt, len(fileParts))
|
||||
}
|
||||
|
||||
var qLen int
|
||||
if len(fileParts) > 0 {
|
||||
qLen = m.appCtrl.RunWithFiles(processedText, fileParts)
|
||||
} else {
|
||||
qLen = m.appCtrl.Run(processedText)
|
||||
}
|
||||
if qLen > 0 {
|
||||
m.queuedMessages = append(m.queuedMessages, displayText)
|
||||
m.layoutDirty = true
|
||||
} else {
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
m.flushStreamAndPendingUserMessages()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5133,8 +5234,9 @@ type mcpPromptResultMsg struct {
|
||||
// executed before-session-switch hook. The hook runs in a goroutine so that
|
||||
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
|
||||
type beforeSessionSwitchResultMsg struct {
|
||||
cancelled bool
|
||||
reason string
|
||||
cancelled bool
|
||||
reason string
|
||||
initialPrompt string
|
||||
}
|
||||
|
||||
// beforeForkResultMsg carries the result of an asynchronously executed
|
||||
|
||||
@@ -1144,3 +1144,128 @@ func TestRenderQueuedMessages_truncatesLongMessages(t *testing.T) {
|
||||
t.Fatalf("expected truncated output to be ≤10 lines, got %d lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// /new <prompt> and ctx.NewSession
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestNewCommand_noPrompt verifies that /new without an argument resets the
|
||||
// session (clears messages, prints the system message) and does NOT submit
|
||||
// any prompt to the controller.
|
||||
func TestNewCommand_noPrompt(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand("")
|
||||
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls for empty prompt, got %v", ctrl.runCalls)
|
||||
}
|
||||
if ctrl.clearMsgCalled == 0 {
|
||||
t.Fatal("expected ClearMessages to be called when no tree session is active")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommand_withPrompt verifies that /new <prompt> submits the prompt
|
||||
// to AppController.Run after clearing the session.
|
||||
func TestNewCommand_withPrompt(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand("continue from where we left off")
|
||||
|
||||
if len(ctrl.runCalls) != 1 {
|
||||
t.Fatalf("expected exactly 1 Run call, got %d (%v)", len(ctrl.runCalls), ctrl.runCalls)
|
||||
}
|
||||
if ctrl.runCalls[0] != "continue from where we left off" {
|
||||
t.Fatalf("unexpected prompt submitted: %q", ctrl.runCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommand_whitespacePromptIsEmpty verifies that an all-whitespace
|
||||
// prompt is treated as empty (no Run call).
|
||||
func TestNewCommand_whitespacePromptIsEmpty(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
_ = m.handleNewCommand(" \n\t ")
|
||||
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls for whitespace-only prompt, got %v", ctrl.runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSessionRequestEvent_signalsResponseCh verifies that
|
||||
// app.NewSessionRequestEvent runs the same /new pipeline and delivers a
|
||||
// nil error to the response channel on success.
|
||||
func TestNewSessionRequestEvent_signalsResponseCh(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
ch := make(chan error, 1)
|
||||
m = sendMsg(m, app.NewSessionRequestEvent{
|
||||
InitialPrompt: "hello from extension",
|
||||
ResponseCh: ch,
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on success, got %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected ResponseCh to receive a value")
|
||||
}
|
||||
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "hello from extension" {
|
||||
t.Fatalf("expected prompt to be submitted to Run, got %v", ctrl.runCalls)
|
||||
}
|
||||
if m.newSessionResultCh != nil {
|
||||
t.Fatal("expected newSessionResultCh to be cleared after signaling")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSessionRequestEvent_cancelledByExtension verifies that when the
|
||||
// before-session-switch hook cancels, the response channel receives an
|
||||
// error.
|
||||
func TestNewSessionRequestEvent_cancelledByExtension(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.cwd = t.TempDir()
|
||||
m.emitBeforeSessionSwitch = func(reason, prompt string) (bool, string) {
|
||||
return true, "vetoed by test"
|
||||
}
|
||||
|
||||
ch := make(chan error, 1)
|
||||
m = sendMsg(m, app.NewSessionRequestEvent{
|
||||
InitialPrompt: "should be cancelled",
|
||||
ResponseCh: ch,
|
||||
})
|
||||
// The before-hook runs in a goroutine, which sends back a
|
||||
// beforeSessionSwitchResultMsg. Pump that synchronously by reading
|
||||
// the SendEvent call indirectly: SendEvent on stub is a no-op so we
|
||||
// need to dispatch the message ourselves to simulate the round trip.
|
||||
sendMsg(m, beforeSessionSwitchResultMsg{
|
||||
cancelled: true,
|
||||
reason: "vetoed by test",
|
||||
initialPrompt: "should be cancelled",
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error on cancellation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "vetoed by test") {
|
||||
t.Fatalf("expected error to mention the veto reason, got %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected ResponseCh to receive a value")
|
||||
}
|
||||
if len(ctrl.runCalls) != 0 {
|
||||
t.Fatalf("expected no Run calls when cancelled, got %v", ctrl.runCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ host, err := kit.NewAgent(ctx,
|
||||
|
||||
Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`,
|
||||
`WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`,
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is
|
||||
`WithProviderURL`, `WithConfigFile`, `WithDebug`, `WithDebugLogger`, and
|
||||
`Ephemeral`. `Option` is
|
||||
a plain `func(*Options)`, so you can define your own. For fields without a
|
||||
`With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task
|
||||
tuning) construct an `Options` value and call `kit.New`.
|
||||
|
||||
@@ -137,6 +137,7 @@ type ExtensionAPI interface {
|
||||
EmitCustomEvent(name, data string)
|
||||
EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string)
|
||||
EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string)
|
||||
EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string)
|
||||
|
||||
// Commands
|
||||
Commands() []ExtensionCommandDef
|
||||
@@ -567,11 +568,20 @@ func (e *extensionAPI) EmitBeforeFork(targetID string, isUserMsg bool, userText
|
||||
}
|
||||
|
||||
func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
|
||||
return e.EmitBeforeSessionSwitchWithPrompt(switchReason, "")
|
||||
}
|
||||
|
||||
// EmitBeforeSessionSwitchWithPrompt is like EmitBeforeSessionSwitch but also
|
||||
// supplies the initial user prompt (if any) that will be submitted as the
|
||||
// first turn of the new session. Extensions inspecting BeforeSessionSwitchEvent
|
||||
// see this value in the event's InitialPrompt field.
|
||||
func (e *extensionAPI) EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string) {
|
||||
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := e.kit.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
|
||||
Reason: switchReason,
|
||||
Reason: switchReason,
|
||||
InitialPrompt: initialPrompt,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
|
||||
+18
-1
@@ -1047,9 +1047,25 @@ type Options struct {
|
||||
AutoCompact bool // Auto-compact when near context limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
|
||||
|
||||
// Debug enables debug logging for the SDK.
|
||||
// Debug enables debug logging for the SDK. When DebugLogger is nil this
|
||||
// flag selects between the default no-op SimpleDebugLogger (Debug=false)
|
||||
// and the built-in console/buffered logger (Debug=true). When DebugLogger
|
||||
// is non-nil this flag is ignored — the supplied logger's
|
||||
// IsDebugEnabled() controls whether downstream code emits messages.
|
||||
Debug bool
|
||||
|
||||
// DebugLogger, if non-nil, routes low-level debug output from the engine
|
||||
// and the MCP tool plumbing to a caller-supplied implementation. This is
|
||||
// the SDK escape hatch for embedders that want to forward debug output
|
||||
// into their own logging system (zap, slog, log/charm, an in-app TUI
|
||||
// panel, etc.) instead of the built-in console logger.
|
||||
//
|
||||
// When nil (default) the Debug bool controls whether the built-in logger
|
||||
// is installed. When non-nil this logger is used unconditionally and the
|
||||
// Debug bool is ignored; the supplied logger's IsDebugEnabled() reports
|
||||
// whether downstream code should bother formatting messages.
|
||||
DebugLogger DebugLogger
|
||||
|
||||
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured
|
||||
// with OAuth support. If the server returns a 401, the handler is
|
||||
@@ -1514,6 +1530,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
ProviderConfig: providerConfig,
|
||||
Debug: debug,
|
||||
DebugLogger: opts.DebugLogger,
|
||||
NoExtensions: noExtensions,
|
||||
MaxSteps: maxSteps,
|
||||
StreamingEnabled: streaming,
|
||||
|
||||
@@ -83,6 +83,17 @@ func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile
|
||||
// WithDebug enables SDK debug logging.
|
||||
func WithDebug() Option { return func(o *Options) { o.Debug = true } }
|
||||
|
||||
// WithDebugLogger installs a caller-supplied [DebugLogger] for low-level
|
||||
// engine and MCP tool plumbing output. When set this overrides the built-in
|
||||
// logger selected by [WithDebug] — messages flow into the supplied logger
|
||||
// unconditionally, and the logger's IsDebugEnabled reports whether downstream
|
||||
// code should bother formatting them. Use this to forward Kit's debug output
|
||||
// into your application's logging system (slog, zap, charm/log, an in-app
|
||||
// panel, etc.).
|
||||
func WithDebugLogger(l DebugLogger) Option {
|
||||
return func(o *Options) { o.DebugLogger = l }
|
||||
}
|
||||
|
||||
// Ephemeral configures an in-memory session with no persistence (equivalent to
|
||||
// Options.NoSession = true).
|
||||
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
|
||||
|
||||
@@ -63,6 +63,52 @@ func TestOptionFunctionsPlumbing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingDebugLogger is a kit.DebugLogger used to verify WithDebugLogger
|
||||
// plumbs the supplied logger into Options. It records each LogDebug call.
|
||||
type recordingDebugLogger struct {
|
||||
enabled bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *recordingDebugLogger) LogDebug(m string) { l.messages = append(l.messages, m) }
|
||||
func (l *recordingDebugLogger) IsDebugEnabled() bool { return l.enabled }
|
||||
|
||||
// TestWithDebugLoggerPlumbing verifies that kit.WithDebugLogger assigns the
|
||||
// supplied logger to Options.DebugLogger. End-to-end propagation into the
|
||||
// engine is covered indirectly by the existing kitsetup tests; this test
|
||||
// pins the SDK-surface contract.
|
||||
func TestWithDebugLoggerPlumbing(t *testing.T) {
|
||||
l := &recordingDebugLogger{enabled: true}
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(l)(o)
|
||||
if o.DebugLogger == nil {
|
||||
t.Fatal("WithDebugLogger: expected Options.DebugLogger to be set")
|
||||
}
|
||||
if o.DebugLogger != l {
|
||||
t.Error("WithDebugLogger: expected the supplied logger to be installed verbatim")
|
||||
}
|
||||
// Sanity: the installed logger satisfies the SDK interface contract.
|
||||
if !o.DebugLogger.IsDebugEnabled() {
|
||||
t.Error("installed logger IsDebugEnabled() returned false")
|
||||
}
|
||||
o.DebugLogger.LogDebug("hello")
|
||||
if len(l.messages) != 1 || l.messages[0] != "hello" {
|
||||
t.Errorf("LogDebug not forwarded; got %v", l.messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithDebugLoggerNilClears verifies that passing a nil logger to
|
||||
// WithDebugLogger clears any previously-installed logger. This lets later
|
||||
// options override earlier ones the same way WithModel / WithStreaming do.
|
||||
func TestWithDebugLoggerNilClears(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
kit.WithDebugLogger(&recordingDebugLogger{enabled: true})(o)
|
||||
kit.WithDebugLogger(nil)(o)
|
||||
if o.DebugLogger != nil {
|
||||
t.Errorf("WithDebugLogger(nil): expected DebugLogger to be cleared; got %#v", o.DebugLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptionOrderingOverrides verifies later options override earlier ones.
|
||||
func TestOptionOrderingOverrides(t *testing.T) {
|
||||
o := &kit.Options{}
|
||||
|
||||
@@ -151,6 +151,7 @@ customModels:
|
||||
name: "My Custom Model"
|
||||
baseUrl: "http://localhost:8080/v1"
|
||||
apiKey: "my-secret-key"
|
||||
apiModelName: "gpt-4-turbo"
|
||||
reasoning: true
|
||||
temperature: true
|
||||
cost:
|
||||
@@ -168,6 +169,7 @@ customModels:
|
||||
| `name` | string | Yes | Display name for the model |
|
||||
| `baseUrl` | string | No | Per-model base URL override; when set, `--provider-url` is not required |
|
||||
| `apiKey` | string | No | Per-model API key override |
|
||||
| `apiModelName` | string | No | Overrides the model identifier sent in API requests; defaults to the config key |
|
||||
| `reasoning` | bool | No | Whether the model supports reasoning/thinking |
|
||||
| `temperature` | bool | No | Whether the model supports temperature adjustment |
|
||||
| `cost.input` | float | No | Cost per 1K input tokens |
|
||||
|
||||
Reference in New Issue
Block a user