mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416d0aaa56 | ||
|
|
661357e4a2 | ||
|
|
09a9e9e877 |
No files matched your search
@@ -21,12 +21,17 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
|
||||
+79
-58
@@ -2496,76 +2496,97 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||||
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||||
// and byte-identical to the pre-deferral output.
|
||||
if !active {
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
for _, tool := range visible {
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, runtimeToolDefinition(tool))
|
||||
}
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions, ""
|
||||
}
|
||||
|
||||
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||||
// deferred tool loads mid-session. The tools block is part of the provider's
|
||||
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||||
// through the system prompt and messages — on EVERY load. Previously the whole
|
||||
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||||
// middle and shifted every later definition. Instead we build three append-only
|
||||
// regions:
|
||||
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
eager = append(eager, runtimeToolDefinition(tool))
|
||||
}
|
||||
|
||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
discovery := ""
|
||||
if active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
} else if active && discovery != "" {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == tools.ToolSearchToolName {
|
||||
definitions[i].Description = discovery
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
sort.Slice(eager, func(left int, right int) bool {
|
||||
return eager[left].Name < eager[right].Name
|
||||
})
|
||||
sort.Slice(loadedTail, func(left int, right int) bool {
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||||
definitions = append(definitions, eager...)
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
definitions = append(definitions, loadedTail...)
|
||||
|
||||
return definitions, discovery
|
||||
}
|
||||
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||
|
||||
pos := func(name string) int {
|
||||
for i := range exposed {
|
||||
if exposed[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
if pos("mcp__srv__beta") != -1 {
|
||||
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||
// fixed base every session pays regardless of the workspace it runs in.
|
||||
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||
got := ApproxTextTokens(prompt)
|
||||
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||
if got > maxBaseSystemPromptTokens {
|
||||
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
}
|
||||
// The first skill is always listed regardless of budget.
|
||||
if !strings.Contains(got, "- skill-0:") {
|
||||
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
}
|
||||
got := skillsContext(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||
{Name: " ", Description: "nameless, should be skipped"},
|
||||
{Name: "reviewer"},
|
||||
}})
|
||||
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||
t.Fatalf("missing available_skills block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||
t.Fatalf("missing commit-writer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- reviewer\n") {
|
||||
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a commit message."},
|
||||
}})
|
||||
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<available_skills>") {
|
||||
t.Fatalf("available_skills block must not appear without skills")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + name
|
||||
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||
line += ": " + truncateForSkillLine(desc)
|
||||
}
|
||||
line += "\n"
|
||||
// Always list at least one skill; past the budget, summarize the remainder
|
||||
// as a count instead of silently dropping it.
|
||||
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
spent += len(line)
|
||||
listed++
|
||||
}
|
||||
if omitted > 0 {
|
||||
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||
}
|
||||
b.WriteString("</available_skills>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
|
||||
@@ -46,14 +46,10 @@ work.
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||
line; a substantial change earns a few short bullets covering what changed, where
|
||||
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||
pad — do not restate the task or narrate steps the user already watched you take.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,9 @@ work.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import "strings"
|
||||
//
|
||||
// Different model families respond to different framing: the GPT family benefits
|
||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
||||
// Claude family is already well-aligned with the core prompt and only needs a
|
||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
||||
// block appended after the core prompt. Classification is by model id (the agent
|
||||
// only has the model string — no registry dependency).
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||
// modelPromptAddendum returns the family-specific block appended after the core
|
||||
// prompt, or "" when the family is unknown or needs no specialization.
|
||||
// Classification is by model id (the agent only has the model string — no registry
|
||||
// dependency).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
|
||||
return openAIPromptAddendum
|
||||
case familyGemini:
|
||||
return geminiPromptAddendum
|
||||
case familyAnthropic:
|
||||
return anthropicPromptAddendum
|
||||
default:
|
||||
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||
// family-specific block; comment discipline now lives in the core prompt
|
||||
// for every model.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||
// the right skill with the skill tool on the first try instead of guessing a name
|
||||
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||
// package needs no dependency on internal/skills.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -189,6 +199,12 @@ type Options struct {
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Skills lists the reusable skills installed for this run (the default skills
|
||||
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||
// gains an <available_skills> block naming them so the model loads the right one
|
||||
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||
// previous prompt byte-for-byte.
|
||||
Skills []SkillInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
|
||||
@@ -758,6 +758,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
|
||||
@@ -508,6 +508,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
|
||||
@@ -3,7 +3,9 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||
}
|
||||
|
||||
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||
// the default skills dir merged with any plugin skill roots, the same set the
|
||||
// plugin-aware skill tool resolves against — as plain data for the agent's system
|
||||
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
|
||||
// the prompt byte-identical.
|
||||
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
|
||||
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||
// line, prefixing the diagnostic kind and appending the most specific locator
|
||||
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
||||
|
||||
@@ -71,7 +71,7 @@ type CompactionPayload struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
const defaultCompactionPreserveLast = 8
|
||||
const defaultCompactionPreserveLast = 6
|
||||
const defaultCompactionMaxPromptChars = 8000
|
||||
|
||||
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
|
||||
|
||||
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Context",
|
||||
"0 tools",
|
||||
"style balanced",
|
||||
"style concise",
|
||||
"root unknown",
|
||||
} {
|
||||
assertContains(t, text, want)
|
||||
|
||||
@@ -559,6 +559,21 @@ func (m model) modelText(args string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// avgTurnLatencyText reports the session's rolling average turn wall-time for
|
||||
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
|
||||
// a turn has completed.
|
||||
func (m model) avgTurnLatencyText() string {
|
||||
if m.turnLatencyCount == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
|
||||
if m.turnTTFTCount > 0 {
|
||||
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
|
||||
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
|
||||
}
|
||||
|
||||
func (m model) contextText() string {
|
||||
toolCount := len(m.registeredTools())
|
||||
return renderCommandCardTranscript(commandCard{
|
||||
@@ -578,6 +593,8 @@ func (m model) contextText() string {
|
||||
{Key: "effort", Value: m.effortDisplay()},
|
||||
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
|
||||
{Key: "usage", Value: m.usageSummaryText()},
|
||||
{Key: "cache", Value: m.cacheEfficiencyText()},
|
||||
{Key: "latency", Value: m.avgTurnLatencyText()},
|
||||
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandNew
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -154,6 +155,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Clear the visible transcript.",
|
||||
kind: commandClear,
|
||||
},
|
||||
{
|
||||
name: "/new",
|
||||
usage: "/new",
|
||||
group: commandGroupSession,
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
|
||||
@@ -105,11 +105,14 @@ func TestClearResetsFlushFrontier(t *testing.T) {
|
||||
m.input.SetValue("/clear")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed > 1 {
|
||||
t.Fatalf("expected frontier reset after /clear, got %d", next.flushed)
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected /clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("expected frontier reset to match cleared transcript (%d rows), got %d", len(next.transcript), next.flushed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestAvgTurnLatencyText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
if got := m.avgTurnLatencyText(); got != "n/a" {
|
||||
t.Fatalf("empty latency = %q, want n/a", got)
|
||||
}
|
||||
m.turnLatencySum = 9 * time.Second
|
||||
m.turnLatencyCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText = %q, want \"4.5s avg (2 turns)\"", got)
|
||||
}
|
||||
// With TTFT recorded, the line also reports time-to-first-token.
|
||||
m.turnTTFTSum = 3 * time.Second
|
||||
m.turnTTFTCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (1.5s to first token, 2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText with ttft = %q, want \"4.5s avg (1.5s to first token, 2 turns)\"", got)
|
||||
}
|
||||
// /new must reset the rolling latency + ttft so a fresh session starts from zero.
|
||||
m.activeSession = sessions.Metadata{SessionID: "x"}
|
||||
next := m.startNewSession()
|
||||
if next.turnLatencyCount != 0 || next.turnLatencySum != 0 || next.turnTTFTCount != 0 || next.turnTTFTSum != 0 {
|
||||
t.Fatalf("startNewSession must reset latency+ttft, got latency=%d/%v ttft=%d/%v", next.turnLatencyCount, next.turnLatencySum, next.turnTTFTCount, next.turnTTFTSum)
|
||||
}
|
||||
}
|
||||
+78
-32
@@ -36,7 +36,7 @@ import (
|
||||
)
|
||||
|
||||
const tuiToolOutputLimit = 240
|
||||
const defaultResponseStyle = "balanced"
|
||||
const defaultResponseStyle = "concise"
|
||||
const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
@@ -100,36 +100,43 @@ type model struct {
|
||||
titledSessions map[string]bool
|
||||
// retitle* drive the sequential /retitle backfill: queued session ids still
|
||||
// awaiting a title, whether a backfill is running, and its progress counters.
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||||
// Reset by /new.
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
@@ -452,6 +459,9 @@ type agentResponseMsg struct {
|
||||
// Turn metadata for settled rows that do not otherwise carry it.
|
||||
turnTools int
|
||||
turnElapsed time.Duration
|
||||
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
|
||||
// tool-only or errored turn). Set only on the success path.
|
||||
ttft time.Duration
|
||||
}
|
||||
|
||||
type agentRowMsg struct {
|
||||
@@ -1859,6 +1869,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.streamingText = ""
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
// Roll the completed run's wall-time into the session's rolling average so
|
||||
// /context can surface typical turn latency, not just token counts.
|
||||
if msg.turnElapsed > 0 {
|
||||
m.turnLatencySum += msg.turnElapsed
|
||||
m.turnLatencyCount++
|
||||
}
|
||||
if msg.ttft > 0 {
|
||||
m.turnTTFTSum += msg.ttft
|
||||
m.turnTTFTCount++
|
||||
}
|
||||
if msg.specReview != nil {
|
||||
m = m.activateSpecReview(*msg.specReview)
|
||||
}
|
||||
@@ -3622,10 +3642,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case commandClear:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
// user cancel first. Idle, /new saves the current session (already on disk)
|
||||
// and clears the conversation in place.
|
||||
if m.pending || m.compactInFlight {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandExit:
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
@@ -4110,6 +4143,9 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
|
||||
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
started := m.now()
|
||||
// firstTokenAt is stamped when the first token (reasoning or text) streams,
|
||||
// so the turn can report time-to-first-token alongside total wall time.
|
||||
var firstTokenAt time.Time
|
||||
toolCalls := 0
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
@@ -4202,6 +4238,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
|
||||
onText := options.OnText
|
||||
options.OnText = func(delta string) {
|
||||
if firstTokenAt.IsZero() {
|
||||
firstTokenAt = m.now()
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) != "" {
|
||||
flushReasoning(m.now())
|
||||
}
|
||||
@@ -4297,6 +4336,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
onReasoning := options.OnReasoning
|
||||
options.OnReasoning = func(delta string) {
|
||||
now := m.now()
|
||||
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
|
||||
firstTokenAt = now
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
|
||||
reasoningStarted = now
|
||||
}
|
||||
@@ -4533,6 +4575,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
ttft := time.Duration(0)
|
||||
if !firstTokenAt.IsZero() {
|
||||
ttft = firstTokenAt.Sub(started)
|
||||
}
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowAssistant,
|
||||
text: result.FinalAnswer,
|
||||
@@ -4550,7 +4596,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
"content": result.FinalAnswer,
|
||||
},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,8 +340,11 @@ func TestClearCommandResetsTranscript(t *testing.T) {
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStartNewSessionResetsState(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.sessionEvents = []sessions.Event{{Type: sessions.EventMessage}}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
// Stage attachments + a queued message that /new must not leak into the new session.
|
||||
m.pendingImages = make([]zeroruntime.ImageBlock, 1)
|
||||
m.pendingImageLabels = []string{"pic.png"}
|
||||
m.pendingDocuments = []pendingDocument{{label: "doc.pdf"}}
|
||||
m.queuedMessage = "queued"
|
||||
|
||||
next := m.startNewSession()
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected active session id cleared, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
if len(next.sessionEvents) != 0 {
|
||||
t.Fatalf("expected session events cleared, got %d", len(next.sessionEvents))
|
||||
}
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected transcript reset to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
// The note must name the prior session id so the user can /resume it.
|
||||
if !transcriptContains(next.transcript, "sess-old") {
|
||||
t.Fatalf("expected note to reference previous session id, got %#v", next.transcript)
|
||||
}
|
||||
// Staged attachments and the queued message must not leak into the new session.
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 || next.queuedMessage != "" {
|
||||
t.Fatalf("startNewSession must clear staged input, got images=%d labels=%d docs=%d queued=%q",
|
||||
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments), next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandStartsFreshSession(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected /new to clear the active session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandDoesNotResetDuringRun(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.pending = true
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
// The safety invariant: /new must never strand an in-flight session.
|
||||
if next.activeSession.SessionID != "sess-old" {
|
||||
t.Fatalf("/new must not reset an in-flight session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
)
|
||||
|
||||
const tuiSessionTitleLimit = 80
|
||||
@@ -39,6 +40,60 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startNewSession abandons the visible conversation and the agent's in-context
|
||||
// history and begins a fresh session in place. The current session already lives
|
||||
// on disk (its events are persisted as they happen), so it stays resumable via
|
||||
// /resume; here we only clear the in-memory conversation and the per-session
|
||||
// usage/compaction counters, then let the next prompt lazily create a new session
|
||||
// through ensureActiveSession — the same seam a cold start uses. Model, provider,
|
||||
// permission mode, and response style are intentionally preserved: /new starts a
|
||||
// clean conversation, not a clean configuration.
|
||||
func (m model) startNewSession() model {
|
||||
previousID := m.activeSession.SessionID
|
||||
|
||||
m.activeSession = sessions.Metadata{}
|
||||
m.sessionEvents = nil
|
||||
|
||||
// Reset the per-session usage + compaction display so the new session starts
|
||||
// from zero instead of inheriting the previous conversation's token/cost totals.
|
||||
if m.usageTracker != nil {
|
||||
m.usageTracker.Reset()
|
||||
}
|
||||
m.lastUsage = usage.Normalized{}
|
||||
m.lastUsageSeen = false
|
||||
m.unpricedRequests = 0
|
||||
m.unpricedTokens = 0
|
||||
m.compactRequests = 0
|
||||
m.compactFrame = 0
|
||||
m.lastCompactResult = nil
|
||||
m.lastCompactError = ""
|
||||
m.turnLatencySum = 0
|
||||
m.turnLatencyCount = 0
|
||||
m.turnTTFTSum = 0
|
||||
m.turnTTFTCount = 0
|
||||
|
||||
// Staged input belongs to the previous conversation. Attachments and a queued
|
||||
// message are only consumed at prompt-submit, so without clearing them here the
|
||||
// fresh session's first prompt would silently inherit the old session's images,
|
||||
// documents, or queued text.
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
m.pendingDocuments = nil
|
||||
m.queuedMessage = ""
|
||||
|
||||
note := "Started a new session."
|
||||
if previousID != "" {
|
||||
note = "New session started. Previous session saved as " + previousID +
|
||||
" — resume it anytime with /resume " + previousID + "."
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: note})
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (model, error) {
|
||||
if m.activeSession.SessionID == "" {
|
||||
return m, nil
|
||||
|
||||
@@ -803,6 +803,25 @@ func (m model) usageSummaryText() string {
|
||||
return usage.FormatSummary(summary) + "; " + formatUnpricedUsage(m.unpricedRequests, m.unpricedTokens)
|
||||
}
|
||||
|
||||
// cacheEfficiencyText reports the session's prompt-cache hit rate for /context so a
|
||||
// user can see whether cache reads are actually saving work. A low rate across
|
||||
// several turns usually means the cacheable prefix is churning (e.g. a tool list
|
||||
// that shifts mid-session), so it flags that case to make slowness diagnosable.
|
||||
func (m model) cacheEfficiencyText() string {
|
||||
if m.usageTracker == nil {
|
||||
return "usage unavailable"
|
||||
}
|
||||
summary := m.usageTracker.Summary()
|
||||
if summary.InputTokens <= 0 {
|
||||
return "n/a"
|
||||
}
|
||||
text := usage.FormatCacheEfficiency(summary)
|
||||
if summary.RecordCount > 5 && summary.CacheHitRate() < 0.5 {
|
||||
text += " — low; prefix may be unstable"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func formatUnpricedUsage(requests int, tokens int) string {
|
||||
requestLabel := "requests"
|
||||
if requests == 1 {
|
||||
|
||||
@@ -148,21 +148,21 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) {
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /style to be handled without starting an agent run")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "active style: balanced") || !transcriptContains(next.transcript, "concise") {
|
||||
if !transcriptContains(next.transcript, "active style: concise") || !transcriptContains(next.transcript, "explanatory") {
|
||||
t.Fatalf("expected style list transcript, got %#v", next.transcript)
|
||||
}
|
||||
|
||||
next.input.SetValue("/style concise")
|
||||
next.input.SetValue("/style explanatory")
|
||||
updated, cmd = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /style concise to be handled without starting an agent run")
|
||||
t.Fatal("expected /style explanatory to be handled without starting an agent run")
|
||||
}
|
||||
if next.responseStyle != "concise" {
|
||||
t.Fatalf("expected concise style, got %q", next.responseStyle)
|
||||
if next.responseStyle != "explanatory" {
|
||||
t.Fatalf("expected explanatory style, got %q", next.responseStyle)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "active style: concise") {
|
||||
if !transcriptContains(next.transcript, "active style: explanatory") {
|
||||
t.Fatalf("expected style switch transcript, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
@@ -621,7 +621,7 @@ func TestUsageEventsUpdateFooterAndContext(t *testing.T) {
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /context to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{"usage 1 request, 120 tokens", "style balanced", "effort auto", "compaction idle"} {
|
||||
for _, want := range []string{"usage 1 request, 120 tokens", "style concise", "effort auto", "compaction idle"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected context transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package usage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSummaryCacheHitRate(t *testing.T) {
|
||||
s := Summary{InputTokens: 13100, CachedInputTokens: 8200}
|
||||
if got := s.CacheHitRate(); got < 0.62 || got > 0.63 {
|
||||
t.Fatalf("CacheHitRate = %v, want ~0.626", got)
|
||||
}
|
||||
if got := (Summary{}).CacheHitRate(); got != 0 {
|
||||
t.Fatalf("empty CacheHitRate = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCacheEfficiency(t *testing.T) {
|
||||
s := Summary{InputTokens: 13100, CachedInputTokens: 8200}
|
||||
if got := FormatCacheEfficiency(s); got != "63% (8,200 cached / 13,100 input)" {
|
||||
t.Fatalf("FormatCacheEfficiency = %q", got)
|
||||
}
|
||||
if got := FormatCacheEfficiency(Summary{}); got != "n/a" {
|
||||
t.Fatalf("empty FormatCacheEfficiency = %q, want n/a", got)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,29 @@ func FormatSummary(summary Summary) string {
|
||||
return fmt.Sprintf("%s %s, %s tokens, %s", comma(summary.RecordCount), requestLabel, comma(summary.TotalTokens), summary.FormattedTotalCost)
|
||||
}
|
||||
|
||||
// CacheHitRate is the fraction of input tokens served from the provider's prompt
|
||||
// cache. CachedInputTokens is clamped to InputTokens in Normalize, so the result is
|
||||
// always in [0,1]; it is 0 when no input has been recorded.
|
||||
func (summary Summary) CacheHitRate() float64 {
|
||||
if summary.InputTokens <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(summary.CachedInputTokens) / float64(summary.InputTokens)
|
||||
}
|
||||
|
||||
// FormatCacheEfficiency renders the prompt-cache hit rate for display, e.g.
|
||||
// "63% (8,200 cached / 13,100 input)", so a user can see whether cache reads are
|
||||
// actually saving work. Returns "n/a" until some input has been recorded.
|
||||
func FormatCacheEfficiency(summary Summary) string {
|
||||
if summary.InputTokens <= 0 {
|
||||
return "n/a"
|
||||
}
|
||||
return fmt.Sprintf("%.0f%% (%s cached / %s input)",
|
||||
summary.CacheHitRate()*100,
|
||||
comma(summary.CachedInputTokens),
|
||||
comma(summary.InputTokens))
|
||||
}
|
||||
|
||||
func addUsageToSummary(summary *Summary, usage Normalized) {
|
||||
summary.InputTokens += usage.InputTokens
|
||||
summary.CachedInputTokens += usage.CachedInputTokens
|
||||
|
||||
Reference in new issue
Block a user