mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd0bc5cee | ||
|
|
12eedef920 |
No files matched your search
+41
-20
@@ -221,6 +221,9 @@ type model struct {
|
||||
// renders the live elapsed time from it so a long or stalled turn never looks
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
// turnTimer is shared with the agent command so both the live status and the
|
||||
// settled "worked for" duration exclude time blocked on a user permission.
|
||||
turnTimer *activeTurnTimer
|
||||
// lastCharTime tracks when the last non-Enter key was received, for paste detection.
|
||||
lastCharTime time.Time
|
||||
// lastKeyTime tracks every keypress timestamp for burst calculation.
|
||||
@@ -3502,7 +3505,7 @@ func (m model) workingStatusLine() string {
|
||||
// (reasoning, waiting on the model, or running a tool).
|
||||
line += zeroTheme.faint.Render(" · " + m.workingActivity())
|
||||
if !m.turnStartedAt.IsZero() {
|
||||
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.now().Sub(m.turnStartedAt)))
|
||||
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.activeTurnElapsed(m.turnStartedAt)))
|
||||
}
|
||||
// Live token estimate so the working line visibly climbs as the model reasons
|
||||
// and writes, instead of a static figure. Shown from the start of the turn (at
|
||||
@@ -3587,7 +3590,7 @@ const quietWorkingHint = 8 * time.Second
|
||||
// the ticking number was the only signal, and it looks identical whether real
|
||||
// (if slow) content is coming or nothing ever will.
|
||||
func (m model) quietGenerationHint() string {
|
||||
if m.activeRunID == 0 {
|
||||
if m.activeRunID == 0 || m.pendingPermission != nil {
|
||||
return ""
|
||||
}
|
||||
last := m.lastStreamActivity
|
||||
@@ -4136,6 +4139,10 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
|
||||
})
|
||||
}
|
||||
m.pendingPermission = nil
|
||||
// Time spent at the prompt is user wait, not provider silence. Restart the
|
||||
// quiet-generation clock so resuming a long-blocked run does not immediately
|
||||
// claim the model has been inactive for the entire approval interval.
|
||||
m.lastStreamActivity = m.now()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -4859,6 +4866,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
// suppressed by a stale preference.
|
||||
m.sidebarHidden = false
|
||||
m.turnStartedAt = m.now()
|
||||
m.turnTimer = newActiveTurnTimer(m.turnStartedAt)
|
||||
m.lastStreamActivity = m.turnStartedAt
|
||||
m.turnStreamedRunes = 0
|
||||
m.spinnerTicking = true
|
||||
@@ -5039,9 +5047,21 @@ 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
|
||||
if m.turnTimer != nil {
|
||||
m.turnTimer.start(started)
|
||||
}
|
||||
// firstTokenElapsed is stamped from the pause-aware turn timer when the
|
||||
// first reasoning or text token streams, so TTFT and total elapsed use
|
||||
// the same clock.
|
||||
var firstTokenElapsed time.Duration
|
||||
firstTokenSeen := false
|
||||
stampFirstToken := func(at time.Time) {
|
||||
if firstTokenSeen {
|
||||
return
|
||||
}
|
||||
firstTokenSeen = true
|
||||
firstTokenElapsed = m.activeTurnElapsedAt(started, at)
|
||||
}
|
||||
toolCalls := 0
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
@@ -5157,11 +5177,10 @@ 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()
|
||||
}
|
||||
now := m.now()
|
||||
stampFirstToken(now)
|
||||
if strings.TrimSpace(reasoningText) != "" {
|
||||
flushReasoning(m.now())
|
||||
flushReasoning(now)
|
||||
}
|
||||
m.sendAgentText(runID, delta)
|
||||
if onText != nil {
|
||||
@@ -5178,6 +5197,12 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
onPermissionRequest := options.OnPermissionRequest
|
||||
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
if m.turnTimer != nil {
|
||||
m.turnTimer.pause(m.now())
|
||||
defer func() {
|
||||
m.turnTimer.resume(m.now())
|
||||
}()
|
||||
}
|
||||
if onPermissionRequest != nil {
|
||||
return onPermissionRequest(ctx, request)
|
||||
}
|
||||
@@ -5255,8 +5280,8 @@ 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(delta) != "" {
|
||||
stampFirstToken(now)
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
|
||||
reasoningStarted = now
|
||||
@@ -5481,7 +5506,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
if runOptions.specDraft {
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
|
||||
@@ -5491,17 +5516,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
ttft := time.Duration(0)
|
||||
if !firstTokenAt.IsZero() {
|
||||
ttft = firstTokenAt.Sub(started)
|
||||
}
|
||||
elapsed := m.activeTurnElapsed(started)
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowAssistant,
|
||||
text: result.FinalAnswer,
|
||||
@@ -5519,7 +5540,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, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: firstTokenElapsed}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,9 +20,10 @@ import (
|
||||
)
|
||||
|
||||
type scriptedProvider struct {
|
||||
scripts [][]zeroruntime.StreamEvent
|
||||
requests []zeroruntime.CompletionRequest
|
||||
calls int
|
||||
scripts [][]zeroruntime.StreamEvent
|
||||
requests []zeroruntime.CompletionRequest
|
||||
beforeCall func(int)
|
||||
calls int
|
||||
}
|
||||
|
||||
func (provider *scriptedProvider) StreamCompletion(
|
||||
@@ -39,6 +41,9 @@ func (provider *scriptedProvider) StreamCompletion(
|
||||
if index >= len(provider.scripts) {
|
||||
index = len(provider.scripts) - 1
|
||||
}
|
||||
if provider.beforeCall != nil {
|
||||
provider.beforeCall(index)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, len(provider.scripts[index]))
|
||||
for _, event := range provider.scripts[index] {
|
||||
ch <- event
|
||||
@@ -358,6 +363,92 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
root := t.TempDir()
|
||||
base := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
||||
var clockNanos atomic.Int64
|
||||
clockNanos.Store(base.UnixNano())
|
||||
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
|
||||
writeFileToolScript("call_write", "notes.txt", "hello"),
|
||||
textScript("write blocked"),
|
||||
}}
|
||||
provider.beforeCall = func(index int) {
|
||||
switch index {
|
||||
case 0:
|
||||
clockNanos.Store(base.Add(2 * time.Second).UnixNano())
|
||||
case 1:
|
||||
clockNanos.Store(base.Add(95 * time.Second).UnixNano())
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
runtimeMessageCh := make(chan tea.Msg, 8)
|
||||
m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh)
|
||||
|
||||
m.now = func() time.Time {
|
||||
return time.Unix(0, clockNanos.Load()).UTC()
|
||||
}
|
||||
m.input.SetValue("write notes")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
}
|
||||
|
||||
finalCh := make(chan tea.Msg, 1)
|
||||
go func() {
|
||||
finalCh <- execCmd(cmd)
|
||||
}()
|
||||
|
||||
for received := 0; received < 4; {
|
||||
runtimeMsg := receiveRuntimeMessage(t, runtimeMessageCh)
|
||||
updated, _ = next.Update(runtimeMsg)
|
||||
next = updated.(model)
|
||||
switch runtimeMsg.(type) {
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
|
||||
continue
|
||||
case permissionRequestMsg:
|
||||
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
|
||||
if status := plainRender(t, next.workingStatusLine()); !strings.Contains(status, "· 2s ·") || strings.Contains(status, "still generating") {
|
||||
t.Fatalf("live elapsed advanced during permission wait: %q", status)
|
||||
}
|
||||
updated, _ = next.Update(testKeyText("d"))
|
||||
next = updated.(model)
|
||||
if hint := next.quietGenerationHint(); hint != "" {
|
||||
t.Fatalf("permission wait leaked into quiet-generation age: %q", hint)
|
||||
}
|
||||
}
|
||||
received++
|
||||
}
|
||||
|
||||
finalMsg := receiveFinalMessage(t, finalCh)
|
||||
response, ok := finalMsg.(agentResponseMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected agent response, got %T", finalMsg)
|
||||
}
|
||||
if response.turnElapsed != 5*time.Second {
|
||||
t.Fatalf("turn elapsed = %s, want 5s of active work", response.turnElapsed)
|
||||
}
|
||||
if response.ttft != 5*time.Second {
|
||||
t.Fatalf("ttft = %s, want 5s with permission wait excluded", response.ttft)
|
||||
}
|
||||
if response.ttft > response.turnElapsed {
|
||||
t.Fatalf("ttft = %s exceeds turn elapsed = %s", response.ttft, response.turnElapsed)
|
||||
}
|
||||
updated, _ = next.Update(finalMsg)
|
||||
next = updated.(model)
|
||||
|
||||
answer, ok := findTranscriptRow(next.transcript, rowAssistant)
|
||||
if !ok {
|
||||
t.Fatalf("expected final assistant row, got %#v", next.transcript)
|
||||
}
|
||||
if answer.turnElapsed != 5*time.Second {
|
||||
t.Fatalf("assistant turn elapsed = %s, want 5s", answer.turnElapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionPromptAllowWritesFileAndRecordsDecision(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// activeTurnTimer measures run time while excluding intervals where execution
|
||||
// is paused waiting for a user decision. It is shared by the Bubble Tea model
|
||||
// and the agent command, so access must remain safe across their goroutines.
|
||||
type activeTurnTimer struct {
|
||||
mu sync.Mutex
|
||||
startedAt time.Time
|
||||
pausedAt time.Time
|
||||
pausedFor time.Duration
|
||||
}
|
||||
|
||||
func newActiveTurnTimer(startedAt time.Time) *activeTurnTimer {
|
||||
return &activeTurnTimer{startedAt: startedAt}
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) start(startedAt time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
timer.startedAt = startedAt
|
||||
timer.pausedAt = time.Time{}
|
||||
timer.pausedFor = 0
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) pause(at time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.startedAt.IsZero() || !timer.pausedAt.IsZero() {
|
||||
return
|
||||
}
|
||||
timer.pausedAt = at
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) resume(at time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.pausedAt.IsZero() {
|
||||
return
|
||||
}
|
||||
if at.After(timer.pausedAt) {
|
||||
timer.pausedFor += at.Sub(timer.pausedAt)
|
||||
}
|
||||
timer.pausedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) elapsed(at time.Time) time.Duration {
|
||||
if timer == nil {
|
||||
return 0
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
end := at
|
||||
if !timer.pausedAt.IsZero() && timer.pausedAt.Before(end) {
|
||||
end = timer.pausedAt
|
||||
}
|
||||
elapsed := end.Sub(timer.startedAt) - timer.pausedFor
|
||||
if elapsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
func (m model) activeTurnElapsed(fallbackStartedAt time.Time) time.Duration {
|
||||
return m.activeTurnElapsedAt(fallbackStartedAt, m.now())
|
||||
}
|
||||
|
||||
func (m model) activeTurnElapsedAt(fallbackStartedAt, at time.Time) time.Duration {
|
||||
if m.turnTimer != nil {
|
||||
return m.turnTimer.elapsed(at)
|
||||
}
|
||||
elapsed := at.Sub(fallbackStartedAt)
|
||||
if elapsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
Reference in new issue
Block a user