mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
resolveSessionQuery reported several matches as a bare sentence, so neither caller could recover which conversations matched: headless and TUI starts both printed "session query ... is ambiguous (N matches)" and exited, with no ids to narrow the query by and no way to open any of the matched sessions. The ambiguity now carries identity: ambiguousSessionQueryError holds the matched entries. resumeQueryTarget reads it with errors.As. On a terminal the TUI start opens the session picker over just those matches. Headless starts, and TUI starts without a terminal, still exit 1, but list each candidate (time, the id --resume matches exactly, summary) and a hint to rerun with one of those ids. The picker's menu building moves into chooseResumeEntry so both paths share it. TestHeadlessResumeAmbiguousQueryListsCandidates fails on the base (stderr only carries the ambiguity sentence) and passes now; TestInteractiveResumeAmbiguousQueryOffersOnlyMatches checks the chooser receives exactly the two matching sessions and its pick is used.
239 lines
6.7 KiB
Go
239 lines
6.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"reasonix/internal/agent"
|
|
)
|
|
|
|
const resumePickerSentinel = "__reasonix_resume_picker__"
|
|
|
|
func splitAllowedToolRules(values []string) ([]string, error) {
|
|
var rules []string
|
|
for _, value := range values {
|
|
start := -1
|
|
depth := 0
|
|
flush := func(end int) {
|
|
if start < 0 {
|
|
return
|
|
}
|
|
if rule := strings.TrimSpace(value[start:end]); rule != "" {
|
|
rules = append(rules, rule)
|
|
}
|
|
start = -1
|
|
}
|
|
for i, r := range value {
|
|
switch r {
|
|
case '(':
|
|
if start < 0 {
|
|
start = i
|
|
}
|
|
depth++
|
|
case ')':
|
|
if depth == 0 {
|
|
return nil, fmt.Errorf("invalid --allowed-tools value %q: unexpected ')'", value)
|
|
}
|
|
depth--
|
|
default:
|
|
if depth == 0 && (r == ',' || unicode.IsSpace(r)) {
|
|
flush(i)
|
|
continue
|
|
}
|
|
if start < 0 {
|
|
start = i
|
|
}
|
|
}
|
|
}
|
|
if depth != 0 {
|
|
return nil, fmt.Errorf("invalid --allowed-tools value %q: unclosed '('", value)
|
|
}
|
|
flush(len(value))
|
|
}
|
|
return uniqueStrings(rules), nil
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasLeadingPrintFlag reports whether a standalone -p/--print token appears in
|
|
// the top-level flag run, i.e. before any "--" terminator. reasonix has no
|
|
// interactive -p, so its presence means the user wants one-shot print mode even
|
|
// when it trails other flags (`reasonix --model X -p "task"`).
|
|
func hasLeadingPrintFlag(args []string) bool {
|
|
for _, arg := range args {
|
|
if arg == "--" {
|
|
return false
|
|
}
|
|
if arg == "-p" || arg == "--print" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripLeadingPrintFlag drops the first standalone -p/--print token before any
|
|
// "--" terminator, leaving the rest (including everything after "--") untouched.
|
|
// Used when re-routing a top-level invocation to `run --print` so the print flag
|
|
// is not duplicated.
|
|
func stripLeadingPrintFlag(args []string) []string {
|
|
out := make([]string, 0, len(args))
|
|
dropped := false
|
|
for i, arg := range args {
|
|
if arg == "--" {
|
|
out = append(out, args[i:]...)
|
|
break
|
|
}
|
|
if !dropped && (arg == "-p" || arg == "--print") {
|
|
dropped = true
|
|
continue
|
|
}
|
|
out = append(out, arg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeOptionalResumeArg gives pflag the optional-value behavior Claude's
|
|
// --resume [value] exposes. Interactive sessions have no positional arguments,
|
|
// so a following non-flag token is unambiguously the resume query.
|
|
func normalizeOptionalResumeArg(args []string) []string {
|
|
out := make([]string, 0, len(args))
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
if (arg == "--resume" || arg == "-r") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
out = append(out, arg+"="+args[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
out = append(out, arg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resolveSessionQuery(dir, query string) (cliResumeTarget, error) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" || query == resumePickerSentinel {
|
|
return cliResumeTarget{}, nil
|
|
}
|
|
if info, err := os.Stat(query); err == nil && !info.IsDir() {
|
|
abs, absErr := filepath.Abs(query)
|
|
if absErr != nil {
|
|
return cliResumeTarget{}, absErr
|
|
}
|
|
return cliResumeTarget{path: abs}, nil
|
|
}
|
|
sessions, err := agent.ListSessions(dir)
|
|
if err != nil {
|
|
return cliResumeTarget{}, fmt.Errorf("list sessions: %w", err)
|
|
}
|
|
// Opaque machine session IDs (session_<hex>) are what --events-jsonl and
|
|
// `session show --json` expose. Match them before preview/partial search so
|
|
// one-shot `run --resume` can resume without scanning private paths (#7429).
|
|
if looksLikeMachineSessionID(query) {
|
|
key, keyErr := loadMachineIdentityKey()
|
|
if keyErr != nil {
|
|
return cliResumeTarget{}, fmt.Errorf("machine identity is unavailable: %w", keyErr)
|
|
}
|
|
for _, session := range sessions {
|
|
if machineSessionIDWithKey(agent.BranchID(session.Path), key) == query {
|
|
return cliResumeTarget{path: session.Path}, nil
|
|
}
|
|
}
|
|
return cliResumeTarget{}, fmt.Errorf("no session matches %q", query)
|
|
}
|
|
// Final-format sessions are the same universe the picker offers: engine
|
|
// mirrors folded and migrated sources hidden, so a transcript and the
|
|
// identity it was imported under never compete as two matches.
|
|
scan := scanWorkspaceResume(context.Background(), dir)
|
|
var exact []resumeEntry
|
|
for _, session := range sessions {
|
|
id := agent.BranchID(session.Path)
|
|
base := filepath.Base(session.Path)
|
|
if query == id || query == base || query == session.Path {
|
|
exact = append(exact, resumeEntry{session: session, target: cliResumeTarget{path: session.Path}})
|
|
}
|
|
}
|
|
for _, entry := range scan.canonical {
|
|
if id := entry.target.ref.SessionID; query == id || query == cliCanonicalRoute(id) {
|
|
exact = append(exact, entry)
|
|
}
|
|
}
|
|
matches := exact
|
|
if len(matches) == 0 {
|
|
lower := strings.ToLower(query)
|
|
for _, session := range scan.legacy {
|
|
haystack := strings.ToLower(strings.Join([]string{
|
|
agent.BranchID(session.Path), filepath.Base(session.Path), session.CustomTitle, session.TopicTitle, session.Preview,
|
|
}, "\n"))
|
|
if strings.Contains(haystack, lower) {
|
|
matches = append(matches, resumeEntry{session: session, target: cliResumeTarget{path: session.Path}})
|
|
}
|
|
}
|
|
for _, entry := range scan.canonical {
|
|
haystack := strings.ToLower(strings.Join([]string{
|
|
entry.target.ref.SessionID, entry.session.CustomTitle, entry.session.Preview,
|
|
}, "\n"))
|
|
if strings.Contains(haystack, lower) {
|
|
matches = append(matches, entry)
|
|
}
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return cliResumeTarget{}, fmt.Errorf("no session matches %q", query)
|
|
case 1:
|
|
return matches[0].target, nil
|
|
default:
|
|
return cliResumeTarget{}, &ambiguousSessionQueryError{query: query, matches: matches}
|
|
}
|
|
}
|
|
|
|
// ambiguousSessionQueryError carries the conversations a --resume query
|
|
// matched, so the caller can list them or offer them in the picker.
|
|
type ambiguousSessionQueryError struct {
|
|
query string
|
|
matches []resumeEntry
|
|
}
|
|
|
|
func (e *ambiguousSessionQueryError) Error() string {
|
|
return fmt.Sprintf("session query %q is ambiguous (%d matches)", e.query, len(e.matches))
|
|
}
|
|
|
|
// looksLikeMachineSessionID reports whether query is the opaque HMAC form
|
|
// emitted by machineSessionIDWithKey (`session_` + 32 lowercase hex chars).
|
|
func looksLikeMachineSessionID(query string) bool {
|
|
const prefix = "session_"
|
|
if !strings.HasPrefix(query, prefix) {
|
|
return false
|
|
}
|
|
hexPart := query[len(prefix):]
|
|
if len(hexPart) != 32 {
|
|
return false
|
|
}
|
|
for i := range len(hexPart) {
|
|
c := hexPart[i]
|
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|