f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
124 lines
4.2 KiB
Go
124 lines
4.2 KiB
Go
package parser
|
|
|
|
import "slices"
|
|
|
|
// TerminationStatus describes how a parsed session appears to have
|
|
// ended. The empty string means "unknown" — caller should leave the
|
|
// stored column NULL.
|
|
type TerminationStatus string
|
|
|
|
const (
|
|
// TerminationAwaitingUser means the agent reached a clear
|
|
// "I'm done, your turn" stopping point: Claude end_turn,
|
|
// Codex task_complete, or equivalent for other agents. UI
|
|
// surfaces this as a calm "waiting" indicator.
|
|
TerminationAwaitingUser TerminationStatus = "awaiting_user"
|
|
|
|
// TerminationClean means the session ended for a non-orphan,
|
|
// non-truncated reason that ISN'T explicitly "agent waiting"
|
|
// (e.g. Claude max_tokens or stop_sequence). Treated as
|
|
// "session done" by the UI — no special indicator.
|
|
TerminationClean TerminationStatus = "clean"
|
|
|
|
// TerminationToolCallPending means the last assistant message
|
|
// emitted a tool_use that never received a matching
|
|
// tool_result. Could be a tool currently running, a permission
|
|
// prompt waiting on the user, or a crashed agent — the JSONL
|
|
// can't distinguish those without runtime info.
|
|
TerminationToolCallPending TerminationStatus = "tool_call_pending"
|
|
|
|
// TerminationTruncated means the session file was cut off
|
|
// mid-write (e.g. last line is invalid JSON).
|
|
TerminationTruncated TerminationStatus = "truncated"
|
|
)
|
|
|
|
// Classify returns a status given a parsed message slice, the
|
|
// last assistant message's stop_reason (or empty when unknown),
|
|
// and a sentinel from the file scanner. Returns "" (unknown) when
|
|
// no classification can be made — for example, an empty message
|
|
// slice from an unparseable file. Truncation takes precedence over
|
|
// tool_call_pending: if the file was cut off mid-write, that's
|
|
// the stronger signal about what went wrong.
|
|
//
|
|
// stopReason values that signal "agent waiting on user input" map
|
|
// to TerminationAwaitingUser. The vocabulary differs per agent:
|
|
// Claude uses "end_turn"; Codex uses "task_complete"; pass through
|
|
// the raw string and the helper recognizes both.
|
|
func Classify(
|
|
messages []ParsedMessage,
|
|
stopReason string,
|
|
fileTruncated bool,
|
|
) TerminationStatus {
|
|
if fileTruncated {
|
|
return TerminationTruncated
|
|
}
|
|
if len(messages) == 0 {
|
|
return ""
|
|
}
|
|
if hasOrphanedToolCall(messages) {
|
|
return TerminationToolCallPending
|
|
}
|
|
// awaiting_user only applies when the assistant's "I'm done"
|
|
// signal is the actual end of the transcript. If a user
|
|
// message follows the last assistant turn, the agent is no
|
|
// longer parked — the user has already replied, so the UI
|
|
// should not show a "waiting for you" indicator.
|
|
lastIsAssistant := messages[len(messages)-1].Role == RoleAssistant
|
|
if lastIsAssistant && isAwaitingUserStopReason(stopReason) {
|
|
return TerminationAwaitingUser
|
|
}
|
|
return TerminationClean
|
|
}
|
|
|
|
// isAwaitingUserStopReason reports whether the given stop_reason
|
|
// (from any agent's vocabulary) means the agent has finished its
|
|
// turn and is parked waiting for the user. The set of accepted
|
|
// values grows as more agents are wired up.
|
|
func isAwaitingUserStopReason(stopReason string) bool {
|
|
switch stopReason {
|
|
case "end_turn", // Claude
|
|
"task_complete": // Codex
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasOrphanedToolCall reports whether the last assistant message has
|
|
// any tool_use blocks that lack a matching tool_result. Only results
|
|
// that appear AFTER the last assistant message resolve its calls —
|
|
// an earlier message reusing the same ToolUseID (rare, but possible
|
|
// in forked sessions or malformed transcripts) must not retroactively
|
|
// mark the final unresolved call as resolved.
|
|
func hasOrphanedToolCall(messages []ParsedMessage) bool {
|
|
lastAssistantIdx := -1
|
|
for i, v := range slices.Backward(messages) {
|
|
if v.Role == RoleAssistant {
|
|
lastAssistantIdx = i
|
|
break
|
|
}
|
|
}
|
|
if lastAssistantIdx == -1 {
|
|
return false
|
|
}
|
|
last := messages[lastAssistantIdx]
|
|
if len(last.ToolCalls) == 0 {
|
|
return false
|
|
}
|
|
|
|
resolved := make(map[string]bool)
|
|
for _, m := range messages[lastAssistantIdx+1:] {
|
|
for _, tr := range m.ToolResults {
|
|
if tr.ToolUseID != "" {
|
|
resolved[tr.ToolUseID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, tc := range last.ToolCalls {
|
|
if tc.ToolUseID != "" && !resolved[tc.ToolUseID] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|