f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
176 lines
4.7 KiB
Go
176 lines
4.7 KiB
Go
package parser
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestClassify(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
messages []ParsedMessage
|
|
stopReason string
|
|
truncated bool
|
|
want TerminationStatus
|
|
}{
|
|
{
|
|
name: "empty messages, not truncated",
|
|
messages: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty messages, truncated wins",
|
|
messages: nil,
|
|
truncated: true,
|
|
want: TerminationTruncated,
|
|
},
|
|
{
|
|
name: "awaiting_user: claude end_turn",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "hello"},
|
|
{Role: RoleAssistant, Content: "hi"},
|
|
},
|
|
stopReason: "end_turn",
|
|
want: TerminationAwaitingUser,
|
|
},
|
|
{
|
|
name: "awaiting_user: codex task_complete",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "build it"},
|
|
{Role: RoleAssistant, Content: "done"},
|
|
},
|
|
stopReason: "task_complete",
|
|
want: TerminationAwaitingUser,
|
|
},
|
|
{
|
|
name: "clean: stop_reason is max_tokens (not awaiting)",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "long task"},
|
|
{Role: RoleAssistant, Content: "response"},
|
|
},
|
|
stopReason: "max_tokens",
|
|
want: TerminationClean,
|
|
},
|
|
{
|
|
name: "clean: no stop_reason recorded falls back to clean",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "hello"},
|
|
{Role: RoleAssistant, Content: "hi"},
|
|
},
|
|
want: TerminationClean,
|
|
},
|
|
{
|
|
name: "clean: tool call resolved by tool result",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "read file"},
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_1", ToolName: "Read"},
|
|
}},
|
|
{Role: RoleUser, ToolResults: []ParsedToolResult{
|
|
{ToolUseID: "toolu_1"},
|
|
}},
|
|
{Role: RoleAssistant, Content: "done"},
|
|
},
|
|
stopReason: "end_turn",
|
|
want: TerminationAwaitingUser,
|
|
},
|
|
{
|
|
name: "tool_call_pending: last assistant has unmatched tool_use",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "read file"},
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_1", ToolName: "Read"},
|
|
}},
|
|
},
|
|
stopReason: "tool_use",
|
|
want: TerminationToolCallPending,
|
|
},
|
|
{
|
|
name: "tool_call_pending: prior turns matched, last has unmatched",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_1"},
|
|
}},
|
|
{Role: RoleUser, ToolResults: []ParsedToolResult{
|
|
{ToolUseID: "toolu_1"},
|
|
}},
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_2"},
|
|
}},
|
|
},
|
|
want: TerminationToolCallPending,
|
|
},
|
|
{
|
|
name: "truncated overrides tool_call_pending",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_1"},
|
|
}},
|
|
},
|
|
truncated: true,
|
|
want: TerminationTruncated,
|
|
},
|
|
{
|
|
name: "ignores empty ToolUseID — falls through to clean/awaiting",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: ""},
|
|
}},
|
|
},
|
|
stopReason: "end_turn",
|
|
want: TerminationAwaitingUser,
|
|
},
|
|
{
|
|
// Regression: an earlier message reusing a ToolUseID
|
|
// must NOT mark a later unresolved call as resolved.
|
|
// hasOrphanedToolCall only counts results that appear
|
|
// strictly AFTER the last assistant message.
|
|
name: "earlier matching result does not resolve final orphan",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_dup"},
|
|
}},
|
|
{Role: RoleUser, ToolResults: []ParsedToolResult{
|
|
{ToolUseID: "toolu_dup"},
|
|
}},
|
|
{Role: RoleAssistant, ToolCalls: []ParsedToolCall{
|
|
{ToolUseID: "toolu_dup"},
|
|
}},
|
|
},
|
|
want: TerminationToolCallPending,
|
|
},
|
|
{
|
|
// Regression: once the user replies after an end_turn,
|
|
// the agent is no longer parked. The last assistant's
|
|
// stop_reason is still end_turn but the transcript has
|
|
// moved on, so awaiting_user would mislead.
|
|
name: "user reply after end_turn is not awaiting_user",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "hello"},
|
|
{Role: RoleAssistant, Content: "hi"},
|
|
{Role: RoleUser, Content: "follow-up"},
|
|
},
|
|
stopReason: "end_turn",
|
|
want: TerminationClean,
|
|
},
|
|
{
|
|
name: "user reply after task_complete is not awaiting_user",
|
|
messages: []ParsedMessage{
|
|
{Role: RoleUser, Content: "build it"},
|
|
{Role: RoleAssistant, Content: "done"},
|
|
{Role: RoleUser, Content: "now test"},
|
|
},
|
|
stopReason: "task_complete",
|
|
want: TerminationClean,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := Classify(tc.messages, tc.stopReason, tc.truncated)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|