Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2994,7 +2994,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n
b.stepMoved(a, live, title)
row := b.callBegan(a, id, turn, event, title)
row.Summary = event.Hint
live.box.Event(appwire.EventTurnTool, row)
live.sayTool(row)
case session.EventToolOutput:
b.callOutput(a, id, live, event)
case session.EventToolFinished:
Expand All @@ -3013,7 +3013,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n
if title == "" {
title = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, ""))
}
live.box.Event(appwire.EventTurnTool, b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded))
live.sayTool(b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded))
b.noteContext(a, live)
// THE SEVENTEENTH PROCESS is refused by the engine, and the turn
// says so once at its end.
Expand Down
216 changes: 216 additions & 0 deletions cmd/codeaf/appspendlive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/appwire"
"github.com/Agent-Field/codeaf/internal/provider"
"github.com/Agent-Field/codeaf/internal/session"
)

// Contract (a), (c), (d): a priced provider call reaches both tool phases
// during a live turn without deadlocking, while open and list keep saved spend.
func TestTurnToolCarriesLiveSpendDuringARunningTurn(t *testing.T) {
testTurnToolSpend(t, .125, .25)
}

// Contract (b), (d): a real turn with no spend omits the JSON key at both
// tool phases and completes under the same deadline as a priced turn.
func TestTurnToolOmitsSpendWhenNothingWasSpent(t *testing.T) {
testTurnToolSpend(t, 0, 0)
}

// Contract (a), (d): failed calls, approval updates and output refreshes carry
// the live reading at each emission, without holding the call-log lock.
func TestTurnToolRestatementsCarryTheCurrentSpend(t *testing.T) {
hold := make(chan struct{})
defer close(hold)
conv := &fakeConversation{cost: .375, hold: hold, script: []session.Event{
{Kind: session.EventToolBegin, CallID: "failed", Tool: "bash"},
{Kind: session.EventToolFailed, CallID: "failed", Tool: "bash", Text: "failed"},
{Kind: session.EventToolBegin, CallID: "long", Tool: "bash"},
}}
backend, rec, agent := workBackend(t, conv)
backend.toolQuiet = 0
if _, err := backend.Send(agent.ID, "read the output", ""); err != nil {
t.Fatal(err)
}
failed := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "failed" && row.Phase == appwire.PhaseEnd })
if failed.SpentUSD == nil || *failed.SpentUSD != .375 || failed.OK == nil || *failed.OK {
t.Fatalf("failed tool row = %+v", failed)
}
toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && row.Phase == appwire.PhaseBegin })
conv.mu.Lock()
conv.cost = .5
conv.mu.Unlock()
conv.raise(session.Event{Kind: session.EventQuestion, Question: &session.Question{
ID: 7, Kind: session.QuestionConsent, Head: "Allow the command?",
Subject: session.SubjectRef{Kind: session.SubjectCall, CallID: "long", Name: "bash"},
Options: session.AnswerOptions(session.QuestionConsent),
}})
asked := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && row.Ask != "" })
if asked.SpentUSD == nil || *asked.SpentUSD != .5 {
t.Fatalf("approval tool row = %+v", asked)
}
conv.mu.Lock()
conv.cost = .75
conv.mu.Unlock()
a, err := backend.find(agent.ID)
if err != nil {
t.Fatal(err)
}
backend.callOutput(a, agent.ID, backend.liveOf(a), session.Event{Kind: session.EventToolOutput, CallID: "long", Text: "still running\n"})
fresh := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && len(row.Tail) == 1 })
if fresh.SpentUSD == nil || *fresh.SpentUSD != .75 {
t.Fatalf("refreshed tool row = %+v", fresh)
}
}

func testTurnToolSpend(t *testing.T, saved, cost float64) {
t.Helper()
d := newUnreadDisk(t)
meta, err := session.LoadMeta(d.dir)
if err != nil {
t.Fatal(err)
}
meta.SpentUSD = saved
if err := session.SaveMeta(d.dir, meta); err != nil {
t.Fatal(err)
}
transcript := filepath.Join(d.dir, "transcript.jsonl")
journal := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"usage\",\"usage\":{\"costUsd\":%g}}\n", d.id, saved)
if err := os.WriteFile(transcript, []byte(journal), 0o600); err != nil {
t.Fatal(err)
}
path := filepath.Join(meta.Workspace, "receipt.txt")
if err := os.WriteFile(path, []byte("the tool ran\n"), 0o600); err != nil {
t.Fatal(err)
}
args, err := json.Marshal(map[string]string{"path": path})
if err != nil {
t.Fatal(err)
}
finish := make(chan struct{})
var once sync.Once
release := func() { once.Do(func() { close(finish) }) }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
Messages []struct {
Role string `json:"role"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
answered := false
for _, message := range request.Messages {
answered = answered || message.Role == "tool"
}
if answered {
select {
case <-finish:
case <-r.Context().Done():
return
}
}
w.Header().Set("Content-Type", "text/event-stream")
if answered {
fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"cost":0}}`+"\n\n")
} else {
fmt.Fprintf(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"live-spend","type":"function","function":{"name":"read","arguments":%q}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"cost":%g}}`+"\n\n", string(args), cost)
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
t.Cleanup(server.Close)
profile := t.TempDir()
var engine *session.Agent
d.backend.open = func(ask appOpenAsk) (*appConn, error) {
var err error
engine, err = session.New(session.Config{
Workspace: ask.Workspace, Place: session.Place{Dir: d.dir}, SessionFile: ask.Transcript,
ProfileDir: profile, Model: "test/model", APIKey: "fixture", BaseURL: server.URL,
OneModel: true, Routing: provider.RoutingOff, DeterministicNames: true,
})
if err != nil {
return nil, err
}
return &appConn{Conv: engine, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() { _ = engine.Close() }}, nil
}
// Release the held response before either the server or backend is closed.
t.Cleanup(release)
d.listed()
if _, err := d.backend.OpenAgent(d.id); err != nil {
t.Fatal(err)
}
if _, err := d.backend.Send(d.id, "read receipt.txt, then finish", ""); err != nil {
t.Fatal(err)
}
for _, phase := range []string{appwire.PhaseBegin, appwire.PhaseEnd} {
row := toolRow(d.rec, t, func(row appwire.TurnTool) bool { return row.Call == "live-spend" && row.Phase == phase })
live := engine.Usage().CostUSD
if live != saved+cost {
t.Fatalf("live meter = %v, want %v", live, saved+cost)
}
if live > 0 && (row.SpentUSD == nil || *row.SpentUSD != live) {
t.Fatalf("%s tool row = %+v, want live spend %v", phase, row, live)
}
raw, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
if _, present := fields["spent_usd"]; present != (live > 0) {
t.Fatalf("%s tool row has wrong spend presence: %s", phase, raw)
}
if phase == appwire.PhaseEnd && (row.OK == nil || !*row.OK || !strings.Contains(strings.Join(row.Tail, "\n"), "the tool ran")) {
t.Fatalf("the tool did not run successfully: %+v", row)
}
opened, err := d.backend.OpenAgent(d.id)
if err != nil || opened.SpentUSD != saved || opened.State != appwire.StateWorking {
t.Fatalf("running agents.open = %+v, %v; want saved spend %v", opened, err, saved)
}
if listed := d.listed(); listed.SpentUSD != saved {
t.Fatalf("running agents.list spend = %v, want saved spend %v", listed.SpentUSD, saved)
}
}
release()
d.rec.waitFor(t, 5*time.Second, appTurnDone)
entries, _, err := d.backend.Entries(d.id, "", 100)
if err != nil {
t.Fatal(err)
}
found := false
for _, entry := range entries {
if entry.Kind != appwire.EntryTool {
continue
}
found = true
raw, err := json.Marshal(entry)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
if _, present := fields["spent_usd"]; present {
t.Fatalf("history tool row carries live spend: %s", raw)
}
}
if !found {
t.Fatal("history did not replay the tool call")
}
}
11 changes: 9 additions & 2 deletions cmd/codeaf/apptools.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ func (b *appBackend) rowLocked(agent string, c *appCall) appwire.TurnTool {
return row
}

// sayTool reads the live meter only after the caller has released its locks,
// because a local conversation's Usage takes the session agent's lock.
func (live *appLive) sayTool(row appwire.TurnTool) {
row.SpentUSD = appFigure(live.conn.Conv.Usage().CostUSD)
live.box.Event(appwire.EventTurnTool, row)
}

// factsLocked is a call's facts with its tail as it stands: three lines while
// it runs, twelve once it has ended.
func (c *appCall) factsLocked() appwire.ToolFacts {
Expand Down Expand Up @@ -468,7 +475,7 @@ func (b *appBackend) callAsked(a *appAgent, agent string, live *appLive, call, a
row = b.rowLocked(agent, c)
})
if known {
live.box.Event(appwire.EventTurnTool, row)
live.sayTool(row)
}
}

Expand Down Expand Up @@ -517,7 +524,7 @@ func (b *appBackend) tickTools(a *appAgent, agent string, live *appLive) func()
}
})
for _, row := range rows {
live.box.Event(appwire.EventTurnTool, row)
live.sayTool(row)
}
}
})
Expand Down
16 changes: 16 additions & 0 deletions docs/changes/unreleased/2612-abandoned-turn-step-seal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: A turn stopped at the second stage keeps its paid steps across a restart
pr: 2612
surface: [chat, engine, app]
invalidates:
- "Was true: a turn let go of at the stop bound recorded its spend only on its abandoned line, which replay never bills, so a restart could save a lower total. Is true now: the turn's paid conversation steps are written as a usage seal without duration, and replay restores them."
---

The abandoned line stays evidence and is never billed; errands on the turn's clock
keep their own usage lines. A settle turn stopping at its bound and a final stage
report now write the same step seal, without duration. Each step seal also saves
the conversation's total to `meta.json`, so after a restart the saved figure Home
and `agents.list` read matches the meter the resumed conversation restores.
Transcripts written before this change cannot recover an abandoned turn's missing
steps.
12 changes: 12 additions & 0 deletions docs/changes/unreleased/2612-replay-late-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: A resumed conversation keeps its late receipts and never saves a lower spend
pr: 2612
surface: [chat, engine, app]
invalidates:
- Replay restored a conversation's spend from usage seals only, so a restart forgot late receipts and the next turn could save a lower total.
- Journal call lines were never read back as spend; a call line marked as a late receipt now restores one call and its charge.
---

Ordinary call lines remain evidence only. A meta row rebuilt from the
transcript counts the same late receipts.
12 changes: 12 additions & 0 deletions docs/changes/unreleased/2612-turn-tool-live-spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: added
title: Tool step events carry the conversation's live spend so far
pr: 2612
surface: [app, docs]
invalidates:
- A running turn said nothing about its conversation's spend until it ended; turn.tool now carries the live figure as spent_usd.
---

`spent_usd` on `turn.tool` is the same live meter `agents.budget.get` answers as
`engine.spent_usd`, so it can be ahead of `Agent.spent_usd`, which is saved at
turn end. Zero or unknown readings and history rows omit it.
5 changes: 5 additions & 0 deletions docs/design/app-v3/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,11 @@ and the reply is empty.
| `turn.thinking_done` | `TurnThinkingDone` |
| `turn.tool` | `TurnTool` |

`turn.tool` optionally carries `spent_usd`, the conversation's live spend when
the step was said (the same meter as `agents.budget.get`'s `engine.spent_usd`,
which can be ahead of the saved `Agent.spent_usd`); zero or unknown readings and
history rows omit it, and absence means the engine did not say.

## Slash catalogue commands and forms

`slash.catalog` lists the shared terminal vocabulary; the table is 54 commands, 93 rows, 31 aliases.
Expand Down
64 changes: 64 additions & 0 deletions internal/appwire/turntool_spend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package appwire

import (
"encoding/json"
"testing"
)

// Contract (a), (b): the registered event schema exposes an optional numeric
// spend, and the JSON event carries a supplied figure while omitting absence.
func TestTurnToolSpendWireContract(t *testing.T) {
out, err := SchemaJSON()
if err != nil {
t.Fatal(err)
}
var doc struct {
Events map[string]struct {
Ref string `json:"$ref"`
} `json:"events"`
Defs map[string]struct {
Properties map[string]struct {
AnyOf []struct {
Type string `json:"type"`
} `json:"anyOf"`
} `json:"properties"`
Required []string `json:"required"`
} `json:"$defs"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if doc.Events[EventTurnTool].Ref != "#/$defs/TurnTool" {
t.Fatalf("turn.tool schema = %+v", doc.Events[EventTurnTool])
}
shape := doc.Defs["TurnTool"]
alts := shape.Properties["spent_usd"].AnyOf
if len(alts) != 2 || alts[0].Type != "number" || alts[1].Type != "null" {
t.Fatalf("TurnTool.spent_usd schema = %+v", alts)
}
for _, field := range shape.Required {
if field == "spent_usd" {
t.Fatal("TurnTool.spent_usd became required")
}
}
if _, present := doc.Defs["Entry"].Properties["spent_usd"]; present {
t.Fatal("history entries acquired live spend")
}
usd := .375
for _, figure := range []*float64{nil, &usd} {
raw, err := json.Marshal(TurnTool{Agent: "a", Turn: 1, Call: "c", Name: "read", Phase: PhaseBegin, SpentUSD: figure})
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
if _, present := fields["spent_usd"]; present != (figure != nil) {
t.Fatalf("wrong spend presence: %s", raw)
}
if figure != nil && string(fields["spent_usd"]) != "0.375" {
t.Fatalf("wrong spend figure: %s", raw)
}
}
}
5 changes: 5 additions & 0 deletions internal/appwire/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,11 @@ type (
Phase string `json:"phase"`
OK *bool `json:"ok,omitempty"`
Summary string `json:"summary,omitempty"`
// SpentUSD is the conversation's live spend when this step was said,
// the same meter agents.budget.get calls engine.spent_usd. It can be
// ahead of Agent.SpentUSD, saved at turn end, and is omitted when zero
// or unknown.
SpentUSD *float64 `json:"spent_usd,omitempty"`
ToolFacts
}
TurnDone struct {
Expand Down
Loading
Loading