Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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
}