Skip to content

Commit 7373376

Browse files
author
SqlRush
committed
Add cost JSON export and duration summary
1 parent 1cf01af commit 7373376

4 files changed

Lines changed: 241 additions & 5 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ M6/CLI 补充:final JSON/stream-json result envelope 现在会在发生 manual
259259

260260
M8 补充:`/compact` built-in local command 现在会产生 compact local result,并在 conversation runner 中触发现有手动 compact runner;命令 metadata 会写入 transcript,但摘要输入使用命令前 history,compact boundary/summary、session memory 和 compact event 复用现有 compact pipeline。完整 TUI compact UI、progress/status 展示和 `/cost`/`/status` 仍未完成。
261261

262-
M8 补充:`/cost` built-in local command 现在会在 conversation runner 中按当前 history 的 message usage 汇总 total cost、input/output/cache token 和 web search/fetch 请求数,作为 local text result 写入 result/transcript,且不会请求模型或打开 MCP;`/cost breakdown` 会保留 slash args 并输出最多 20 条带 usage 的消息级成本/token 贡献。完整 TUI cost panel、session duration 和 account/billing 细节仍未完成。
262+
M8 补充:`/cost` built-in local command 现在会在 conversation runner 中按当前 history 的 message usage 汇总 total cost、input/output/cache token 和 web search/fetch 请求数,作为 local text result 写入 result/transcript,且不会请求模型或打开 MCP;`/cost breakdown` 会保留 slash args 并输出最多 20 条带 usage 的消息级成本/token 贡献`/cost json|export` 会输出同口径 JSON 汇总和 message breakdown;当 history/transcript 含 timestamp 时,文本与 JSON 都会给出 session started/updated/duration。完整 TUI cost panel 和 account/billing 细节仍未完成。
263263

264264
M8 补充:`/mcp refresh`/`/mcp reload` 现在会走 no-query 本地刷新路径,重新检查 settings 文件变化并刷新 plugin MCP server app-state,返回 settings changed、plugin MCP server count 和 configured MCP server count;该路径不请求模型,也不声称重启 turn-scoped MCP toolset 连接。
265265

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ test/parity/ # golden tests against TS/official behavior
314314
- 本轮补充:现有 Go 内置 slash command metadata 继续贴近官方源快照,补齐 `config`/`resume`/`clear` 的 aliases(`settings``continue``reset``new`),以及 `mcp`/`resume`/`model` 的 argument hint、`mcp`/`status`/`model` 的 immediate 标记和部分官方描述;大量内置 command 的真实 local/local-jsx UI 执行仍未宣称完成。
315315
- 本轮补充:slash command 现在有基础 local command result 抽象,`/clear` 不再落入 unsupported 分支,会生成 local text result、保留 command metadata message,并且不会请求模型;REPL screen 已提供 clear-result 消费路径,可清空 transcript viewport/selection/reverse-search 并按当前 draft 重算 paste/image 引用编号;完整 local command text/compact/skip 全语义、`/cost`/`/status`/`/compact` 和 local-jsx UI 执行仍未宣称完成。
316316
- 本轮补充:`/compact` 的 headless CLI 路径补齐 JSON/stream-json 端到端回归覆盖:从 `--resume` 历史触发 manual compact 时只请求 compact summary、不把 command metadata 泄露进 compact prompt,final result 与 compact event 都暴露 manual trigger、userContext 和 messagesSummarized metadata。
317-
- 本轮补充:`/cost status``/cost current``/cost usage` 现在复用 `/cost` totals no-query 本地路径,未知 cost 子命令仍保持显式未实现提示,`/cost breakdown`/`show` 继续输出逐消息明细。
317+
- 本轮补充:`/cost status``/cost current``/cost usage` 现在复用 `/cost` totals no-query 本地路径,`/cost breakdown`/`show` 继续输出逐消息明细`/cost json|export` 会输出同口径 JSON 汇总和 message breakdown;当 history/transcript 含 timestamp 时,文本与 JSON 都会给出 session started/updated/duration,未知 cost 子命令会返回 usage 而不是未实现占位
318318
- 本轮补充:`/memory files``/memory ls` 现在复用 `/memory list` 的 no-query 本地记忆文件列表路径,`/memory status` 仍保持会话/相关记忆概览。
319319
- 本轮补充:`/memory save|add|write <relative.md> <content>``/memory remove|rm|delete <relative.md>` 现在可在配置的 relevant memory directory 内安全维护 markdown 记忆文件,拒绝未配置目录、越界路径、非 markdown 文件、目录和 symlink 目标,并保持 no-query 本地结果。
320320
- 本轮补充:`/status <section>` 现在复用 `/status show <section>` 的 section alias normalization,`/status integrations``/status diagnostics` 等直接调用不会再落入 unsupported subcommand 分支。

internal/conversation/run.go

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,8 +632,12 @@ func formatCostSummary(raw string, history []contracts.Message) string {
632632
case "summary", "total", "totals", "status", "current", "usage":
633633
case "show", "breakdown", "details", "detail":
634634
return formatCostBreakdown(history)
635+
case "json", "export":
636+
return formatCostJSON(history)
637+
case "help", "-h", "--help":
638+
return costUsageText()
635639
default:
636-
return "Cost subcommand is not implemented in the Go runtime yet: " + strings.Join(args, " ")
640+
return "Unknown cost subcommand: " + strings.Join(args, " ") + "\n" + costUsageText()
637641
}
638642
}
639643
return formatCostTotals(history)
@@ -715,7 +719,7 @@ func formatCostTotals(history []contracts.Message) string {
715719
if !found {
716720
return "No cost data available for this session."
717721
}
718-
return fmt.Sprintf(
722+
lines := []string{fmt.Sprintf(
719723
"Total cost: $%.6f\nInput tokens: %d\nOutput tokens: %d\nCache creation input tokens: %d\nCache read input tokens: %d\nWeb search requests: %d\nWeb fetch requests: %d",
720724
usage.CostUSD,
721725
usage.InputTokens,
@@ -724,7 +728,15 @@ func formatCostTotals(history []contracts.Message) string {
724728
usage.CacheReadInputTokens,
725729
usage.ServerToolUse.WebSearchRequests,
726730
usage.ServerToolUse.WebFetchRequests,
727-
)
731+
)}
732+
if timing, ok := costSessionTiming(history); ok {
733+
lines = append(lines,
734+
"Session started: "+timing.StartedAt,
735+
"Session updated: "+timing.UpdatedAt,
736+
"Session duration: "+timing.Duration,
737+
)
738+
}
739+
return strings.Join(lines, "\n")
728740
}
729741

730742
func formatCostBreakdown(history []contracts.Message) string {
@@ -736,6 +748,13 @@ func formatCostBreakdown(history []contracts.Message) string {
736748
"Cost breakdown",
737749
fmt.Sprintf("Total cost: $%.6f", total.CostUSD),
738750
}
751+
if timing, ok := costSessionTiming(history); ok {
752+
lines = append(lines,
753+
"Session started: "+timing.StartedAt,
754+
"Session updated: "+timing.UpdatedAt,
755+
"Session duration: "+timing.Duration,
756+
)
757+
}
739758
var withUsage int
740759
for index, message := range history {
741760
if message.Usage == nil || !usageHasValues(*message.Usage) {
@@ -765,6 +784,120 @@ func formatCostBreakdown(history []contracts.Message) string {
765784
return strings.Join(lines, "\n")
766785
}
767786

787+
type costTimingSummary struct {
788+
StartedAt string `json:"started_at,omitempty"`
789+
UpdatedAt string `json:"updated_at,omitempty"`
790+
Duration string `json:"duration,omitempty"`
791+
Seconds int64 `json:"duration_seconds,omitempty"`
792+
Messages int `json:"messages_with_timestamps,omitempty"`
793+
}
794+
795+
type costJSONMessage struct {
796+
Label string `json:"label"`
797+
Type string `json:"type,omitempty"`
798+
ID string `json:"id,omitempty"`
799+
UUID string `json:"uuid,omitempty"`
800+
Model string `json:"model,omitempty"`
801+
Timestamp string `json:"timestamp,omitempty"`
802+
Usage contracts.Usage `json:"usage"`
803+
}
804+
805+
type costJSONSummary struct {
806+
Available bool `json:"available"`
807+
Messages int `json:"messages"`
808+
MessagesWithUsage int `json:"messages_with_usage"`
809+
TotalCostUSD float64 `json:"total_cost_usd,omitempty"`
810+
Usage contracts.Usage `json:"usage,omitempty"`
811+
Timing *costTimingSummary `json:"timing,omitempty"`
812+
Breakdown []costJSONMessage `json:"breakdown,omitempty"`
813+
}
814+
815+
func formatCostJSON(history []contracts.Message) string {
816+
usage, found := historyUsage(history)
817+
out := costJSONSummary{
818+
Available: found,
819+
Messages: len(history),
820+
MessagesWithUsage: countUsageMessages(history),
821+
}
822+
if found {
823+
out.TotalCostUSD = usage.CostUSD
824+
out.Usage = usage
825+
for index, message := range history {
826+
if message.Usage == nil || !usageHasValues(*message.Usage) {
827+
continue
828+
}
829+
out.Breakdown = append(out.Breakdown, costJSONMessage{
830+
Label: costMessageLabel(message, index),
831+
Type: string(message.Type),
832+
ID: strings.TrimSpace(message.ID),
833+
UUID: strings.TrimSpace(string(message.UUID)),
834+
Model: strings.TrimSpace(message.Model),
835+
Timestamp: strings.TrimSpace(message.Timestamp),
836+
Usage: *message.Usage,
837+
})
838+
}
839+
}
840+
if timing, ok := costSessionTiming(history); ok {
841+
out.Timing = &timing
842+
}
843+
data, err := json.MarshalIndent(out, "", " ")
844+
if err != nil {
845+
return `{"available":false,"error":"failed to encode cost summary"}`
846+
}
847+
return string(data)
848+
}
849+
850+
func costUsageText() string {
851+
return "Usage: /cost [summary|status|current|usage|breakdown|show|details|json|export]"
852+
}
853+
854+
func costSessionTiming(history []contracts.Message) (costTimingSummary, bool) {
855+
var first time.Time
856+
var last time.Time
857+
var count int
858+
for _, message := range history {
859+
when, ok := parseMessageTimestamp(message.Timestamp)
860+
if !ok {
861+
continue
862+
}
863+
if count == 0 || when.Before(first) {
864+
first = when
865+
}
866+
if count == 0 || when.After(last) {
867+
last = when
868+
}
869+
count++
870+
}
871+
if count == 0 {
872+
return costTimingSummary{}, false
873+
}
874+
duration := last.Sub(first)
875+
if duration < 0 {
876+
duration = 0
877+
}
878+
return costTimingSummary{
879+
StartedAt: first.UTC().Format(time.RFC3339Nano),
880+
UpdatedAt: last.UTC().Format(time.RFC3339Nano),
881+
Duration: duration.Round(time.Second).String(),
882+
Seconds: int64(duration.Seconds()),
883+
Messages: count,
884+
}, true
885+
}
886+
887+
func parseMessageTimestamp(value string) (time.Time, bool) {
888+
value = strings.TrimSpace(value)
889+
if value == "" {
890+
return time.Time{}, false
891+
}
892+
if when, err := time.Parse(time.RFC3339Nano, value); err == nil {
893+
return when, true
894+
}
895+
if when, err := time.Parse(time.RFC3339, value); err == nil {
896+
return when, true
897+
}
898+
return time.Time{}, false
899+
}
900+
768901
func (r Runner) formatConversationSummary(raw string, history []contracts.Message) string {
769902
sessionID := string(r.SessionID)
770903
if sessionID == "" {

internal/conversation/run_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2481,6 +2481,109 @@ func TestRunnerCostSlashCommandReportsBreakdown(t *testing.T) {
24812481
}
24822482
}
24832483

2484+
func TestRunnerCostSlashCommandReportsJSONAndDuration(t *testing.T) {
2485+
client := &fakeClient{}
2486+
runner := Runner{Client: client, SessionID: "sess_cost_json"}
2487+
firstUsage := contracts.Usage{
2488+
InputTokens: 10,
2489+
OutputTokens: 20,
2490+
CostUSD: 0.25,
2491+
}
2492+
secondUsage := contracts.Usage{
2493+
InputTokens: 5,
2494+
OutputTokens: 7,
2495+
CostUSD: 0.5,
2496+
}
2497+
user := messages.UserText("old one")
2498+
user.Timestamp = time.Unix(90, 0).UTC().Format(time.RFC3339Nano)
2499+
first := messages.AssistantText("old two", "sonnet", &firstUsage)
2500+
first.UUID = "cost_json_one"
2501+
first.Timestamp = time.Unix(100, 0).UTC().Format(time.RFC3339Nano)
2502+
second := messages.AssistantText("old three", "opus", &secondUsage)
2503+
second.UUID = "cost_json_two"
2504+
second.Timestamp = time.Unix(160, 0).UTC().Format(time.RFC3339Nano)
2505+
history := []contracts.Message{user, first, second}
2506+
2507+
result, err := runner.RunTurn(context.Background(), history, messages.UserText("/cost json"))
2508+
if err != nil {
2509+
t.Fatal(err)
2510+
}
2511+
if len(client.requests) != 0 {
2512+
t.Fatalf("model should not be queried, requests = %#v", client.requests)
2513+
}
2514+
var payload struct {
2515+
Available bool `json:"available"`
2516+
Messages int `json:"messages"`
2517+
MessagesWithUsage int `json:"messages_with_usage"`
2518+
TotalCostUSD float64 `json:"total_cost_usd"`
2519+
Usage contracts.Usage `json:"usage"`
2520+
Timing struct {
2521+
StartedAt string `json:"started_at"`
2522+
UpdatedAt string `json:"updated_at"`
2523+
Duration string `json:"duration"`
2524+
Seconds int64 `json:"duration_seconds"`
2525+
Messages int `json:"messages_with_timestamps"`
2526+
} `json:"timing"`
2527+
Breakdown []struct {
2528+
Label string `json:"label"`
2529+
Model string `json:"model"`
2530+
Timestamp string `json:"timestamp"`
2531+
Usage contracts.Usage `json:"usage"`
2532+
} `json:"breakdown"`
2533+
}
2534+
text := result.Messages[1].Content[0].Text
2535+
if err := json.Unmarshal([]byte(text), &payload); err != nil {
2536+
t.Fatalf("cost json should be valid JSON: %v\n%s", err, text)
2537+
}
2538+
if !payload.Available || payload.Messages != 3 || payload.MessagesWithUsage != 2 {
2539+
t.Fatalf("cost json counts = %#v", payload)
2540+
}
2541+
if payload.TotalCostUSD != 0.75 || payload.Usage.InputTokens != 15 || payload.Usage.OutputTokens != 27 {
2542+
t.Fatalf("cost json usage = %#v", payload)
2543+
}
2544+
if payload.Timing.Duration != "1m10s" || payload.Timing.Seconds != 70 || payload.Timing.Messages != 3 {
2545+
t.Fatalf("cost json timing = %#v", payload.Timing)
2546+
}
2547+
if len(payload.Breakdown) != 2 || payload.Breakdown[0].Label != "assistant cost_json_one (sonnet)" || payload.Breakdown[1].Model != "opus" {
2548+
t.Fatalf("cost json breakdown = %#v", payload.Breakdown)
2549+
}
2550+
2551+
result, err = runner.RunTurn(context.Background(), history, messages.UserText("/cost"))
2552+
if err != nil {
2553+
t.Fatal(err)
2554+
}
2555+
text = result.Messages[1].Content[0].Text
2556+
for _, want := range []string{
2557+
"Session started: 1970-01-01T00:01:30Z",
2558+
"Session updated: 1970-01-01T00:02:40Z",
2559+
"Session duration: 1m10s",
2560+
} {
2561+
if !strings.Contains(text, want) {
2562+
t.Fatalf("cost duration missing %q: %q", want, text)
2563+
}
2564+
}
2565+
}
2566+
2567+
func TestRunnerCostSlashCommandReportsUsageForUnknownSubcommand(t *testing.T) {
2568+
runner := Runner{Client: &fakeClient{}, SessionID: "sess_cost_unknown"}
2569+
result, err := runner.RunTurn(context.Background(), nil, messages.UserText("/cost nope"))
2570+
if err != nil {
2571+
t.Fatal(err)
2572+
}
2573+
got := result.Messages[1].Content[0].Text
2574+
for _, want := range []string{
2575+
"Unknown cost subcommand: nope",
2576+
"Usage: /cost [summary|status|current|usage|breakdown|show|details|json|export]",
2577+
} {
2578+
if !strings.Contains(got, want) {
2579+
t.Fatalf("unknown cost result missing %q: %q", want, got)
2580+
}
2581+
}
2582+
if strings.Contains(got, "not implemented") {
2583+
t.Fatalf("unknown cost should not report not implemented: %q", got)
2584+
}
2585+
}
2586+
24842587
func TestRunnerExecutesSummarySlashCommandWithoutQuery(t *testing.T) {
24852588
client := &fakeClient{}
24862589
runner := Runner{

0 commit comments

Comments
 (0)