@@ -43,9 +43,10 @@ func parseCodebuffSession(
4343 // Read chat-meta.json for session name and timing hints.
4444 meta := readCodebuffChatMeta (chatMetaPath )
4545
46- // Model name is the raw agentType from run-state.json
47- // (e.g. "base2-free-deepseek", "base2-free-mimo").
48- model := rs .AgentType
46+ // The actual LLM model is selected server-side based on the agentType
47+ // template and can change mid-session. The on-disk format does not
48+ // persist the actual model, so leave it unknown.
49+ model := ""
4950
5051 // Read and parse the chat messages.
5152 data , err := os .ReadFile (chatMessagesPath )
@@ -105,15 +106,14 @@ func parseCodebuffSession(
105106 }
106107 }
107108
108- // Determine agent label from run-state agentType field.
109+ // Determine agent type from run-state agentType field.
109110 // Sessions with "free" in the agentType are Freebuff, others are Codebuff.
110- // Both share the same on-disk layout and the same agent type (Codebuff)
111- // so that lifecycle operations (reconciliation, deletion, baselines)
112- // keyed by agent type work correctly. The UI distinguishes them via
113- // AgentLabel.
111+ // Both share the same on-disk layout; the parser splits them by type
112+ // so the UI can filter each agent independently.
114113 agent := AgentCodebuff
115114 agentLabel := "Codebuff"
116115 if strings .Contains (strings .ToLower (rs .AgentType ), "free" ) {
116+ agent = AgentFreebuff
117117 agentLabel = "Freebuff"
118118 }
119119
@@ -178,37 +178,31 @@ func parseCodebuffSession(
178178 File : fileInfo ,
179179 }
180180
181- // Token counts from run-state.
182- if rs .ContextTokenCount > 0 {
183- sess .PeakContextTokens = rs .ContextTokenCount
184- sess .HasPeakContextTokens = true
185- }
186-
187- sess .aggregateTokenPresenceKnown =
188- sess .HasTotalOutputTokens || sess .HasPeakContextTokens
181+ // contextTokenCount from run-state.json is the final per-step context
182+ // count, not the peak. Compaction can make the final value lower than
183+ // the true peak, so we cannot reliably derive PeakContextTokens from
184+ // this value. Leave peak context unavailable.
189185
190- // Emit usage event with clean model name for catalog pricing.
191- // Credits are billing units (1 credit = $0.01), mapped to CostUSD.
192- if model != "" {
193- evt := ParsedUsageEvent {
186+ // Emit usage event for reported credits. The actual model is unknown
187+ // (selected server-side, can change mid-session), so Model is left
188+ // empty. Credits are billing units (1 credit = $0.01), mapped to
189+ // CostUSD for cost display.
190+ if rs .CreditsUsed > 0 {
191+ cost := rs .CreditsUsed * 0.01
192+ sess .UsageEvents = []ParsedUsageEvent {{
194193 SessionID : fullID ,
195194 Source : "session" ,
196- Model : model ,
197195 OccurredAt : func () string {
198196 if ! endedAt .IsZero () {
199197 return endedAt .Format (time .RFC3339Nano )
200198 }
201199 return startedAt .Format (time .RFC3339Nano )
202200 }(),
203- DedupKey : "session:" + fullID ,
204- }
205- if rs .CreditsUsed > 0 {
206- cost := rs .CreditsUsed * 0.01
207- evt .CostUSD = & cost
208- evt .CostStatus = "reported"
209- evt .CostSource = "session"
210- }
211- sess .UsageEvents = []ParsedUsageEvent {evt }
201+ CostUSD : & cost ,
202+ CostStatus : "reported" ,
203+ CostSource : "session" ,
204+ DedupKey : "session:" + fullID ,
205+ }}
212206 }
213207
214208 return sess , msgs , nil
@@ -440,14 +434,35 @@ func parseCodebuffMessages(
440434 startedAt time.Time
441435 endedAt time.Time
442436 ordinal int
437+ // Track the current date for cross-midnight sessions. Start with
438+ // the session directory date and advance when time-of-day wraps
439+ // past midnight.
440+ currentDate = sessionDate
441+ prevHour = - 1
443442 )
444443
445444 root .ForEach (func (_ , msg gjson.Result ) bool {
446445 variant := msg .Get ("variant" ).Str
447446 ts := parseCodebuffTimestamp (
448- msg .Get ("timestamp" ).Str , sessionDate ,
447+ msg .Get ("timestamp" ).Str , currentDate ,
449448 )
450449
450+ // Detect midnight rollover for time-only timestamps: if the
451+ // parsed hour is less than the previous hour, we've crossed
452+ // midnight and should advance the date.
453+ if ! ts .IsZero () && prevHour >= 0 {
454+ if ts .Hour () < prevHour {
455+ currentDate = currentDate .AddDate (0 , 0 , 1 )
456+ // Re-parse with the advanced date.
457+ ts = parseCodebuffTimestamp (
458+ msg .Get ("timestamp" ).Str , currentDate ,
459+ )
460+ }
461+ }
462+ if ! ts .IsZero () {
463+ prevHour = ts .Hour ()
464+ }
465+
451466 if ! ts .IsZero () {
452467 if startedAt .IsZero () || ts .Before (startedAt ) {
453468 startedAt = ts
@@ -539,12 +554,11 @@ func parseCodebuffAIMessage(
539554 }
540555
541556 var (
542- out []ParsedMessage
543- thinkingBuf []string
544- textBuf []string
545- toolCalls []ParsedToolCall
546- toolResults []ParsedToolResult
547- pendingSys []string
557+ out []ParsedMessage
558+ thinkingBuf []string
559+ textBuf []string
560+ toolCalls []ParsedToolCall
561+ toolResults []ParsedToolResult
548562 )
549563
550564 // flushText emits any accumulated thinking and text as assistant messages,
@@ -687,45 +701,73 @@ func parseCodebuffAIMessage(
687701 }
688702 toolCalls = append (toolCalls , tc )
689703
690- // Agent output goes into text buffer for the next flush.
704+ // Emit agent output text immediately associated with the
705+ // tool call, not deferred to a later flush.
691706 if output := block .Get ("content" ); output .Exists () && output .Str != "" {
692707 prefix := agentName
693708 if agentType != "" {
694709 prefix = agentType + ":" + agentName
695710 }
711+ // Flush accumulated tool calls first, then emit output.
712+ flushTools ()
696713 textBuf = append (textBuf ,
697714 "[" + prefix + " (" + status + ")]\n " + output .Str )
715+ flushText ()
698716 }
699717
700718 case "mode-divider" :
701719 flushText ()
702720 flushTools ()
703721 mode := block .Get ("mode" ).Str
704722 if mode != "" {
705- pendingSys = append (pendingSys , "[Mode: " + mode + "]" )
723+ // Emit system blocks immediately, not deferred.
724+ out = append (out , ParsedMessage {
725+ Role : RoleSystem ,
726+ Content : "[Mode: " + mode + "]" ,
727+ Timestamp : ts ,
728+ ContentLength : len ("[Mode: " + mode + "]" ),
729+ IsSystem : true ,
730+ })
706731 }
707732
708733 case "plan" :
709734 flushText ()
710735 flushTools ()
711736 content := block .Get ("content" ).Str
712737 if strings .TrimSpace (content ) != "" {
713- pendingSys = append (pendingSys , "[Plan]\n " + content )
738+ // Emit system blocks immediately, not deferred.
739+ out = append (out , ParsedMessage {
740+ Role : RoleSystem ,
741+ Content : "[Plan]\n " + content ,
742+ Timestamp : ts ,
743+ ContentLength : len ("[Plan]\n " + content ),
744+ IsSystem : true ,
745+ })
714746 }
715747
716748 case "ask-user" :
717749 flushText ()
718750 flushTools ()
719751 questions := block .Get ("questions" )
720752 if questions .IsArray () {
753+ var parts []string
721754 questions .ForEach (func (_ , q gjson.Result ) bool {
722755 questionText := q .Get ("question" ).Str
723756 if strings .TrimSpace (questionText ) != "" {
724- pendingSys = append (pendingSys ,
725- "[Agent asked] " + questionText )
757+ parts = append (parts , "[Agent asked] " + questionText )
726758 }
727759 return true
728760 })
761+ if len (parts ) > 0 {
762+ content := strings .Join (parts , "\n " )
763+ out = append (out , ParsedMessage {
764+ Role : RoleSystem ,
765+ Content : content ,
766+ Timestamp : ts ,
767+ ContentLength : len (content ),
768+ IsSystem : true ,
769+ })
770+ }
729771 }
730772
731773 case "image" :
@@ -743,18 +785,6 @@ func parseCodebuffAIMessage(
743785 flushText ()
744786 flushTools ()
745787
746- // Emit system messages after all other content.
747- if len (pendingSys ) > 0 {
748- sysContent := strings .Join (pendingSys , "\n " )
749- out = append (out , ParsedMessage {
750- Role : RoleSystem ,
751- Content : sysContent ,
752- Timestamp : ts ,
753- ContentLength : len (sysContent ),
754- IsSystem : true ,
755- })
756- }
757-
758788 if len (out ) == 0 {
759789 return nil
760790 }
0 commit comments