Share the plan action dispatch and the Ollama-style probe
runPlanAction replaces the two copies of the plan switch (terminal and ACP), with a test for every action; probeAPIPS replaces the two copies of the llmman/ollama probe. Also places the SC2016 directive on the printf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335ef35 parent: 33e0ee8 modified
02-build-releases.sh +1 -1 | @@ -134,8 +134,8 @@ echo " ✅ SHA256SUMS" | ||
| 134 | 134 | downloadTable() { |
| 135 | 135 | printf '| Platform | Download |\n|---|---|\n' |
| 136 | 136 | for platform in "${PLATFORMS[@]}"; do |
| 137 | - # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution | |
| 138 | 137 | local goos="${platform%/*}" goarch="${platform#*/}" |
| 138 | + # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution | |
| 139 | 139 | printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")" |
| 140 | 140 | done |
| 141 | 141 | } |
| @@ -134,8 +134,8 @@ echo " ✅ SHA256SUMS" | |||
| 134 | downloadTable() { | 134 | downloadTable() { |
| 135 | printf '| Platform | Download |\n|---|---|\n' | 135 | printf '| Platform | Download |\n|---|---|\n' |
| 136 | for platform in "${PLATFORMS[@]}"; do | 136 | for platform in "${PLATFORMS[@]}"; do |
| 137 | - # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution | ||
| 138 | local goos="${platform%/*}" goarch="${platform#*/}" | 137 | local goos="${platform%/*}" goarch="${platform#*/}" |
| 138 | + # shellcheck disable=SC2016 # the backticks are Markdown code spans, not a command substitution | ||
| 139 | printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")" | 139 | printf '| %s | `%s` |\n' "${platform}" "$(assetName "${goos}" "${goarch}")" |
| 140 | done | 140 | done |
| 141 | } | 141 | } |
modified
internal/engine/apips.go +19 -0 | @@ -29,6 +29,25 @@ func (l modelList) has(id string) bool { | ||
| 29 | 29 | return false |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | +// probeAPIPS is the probe the Ollama-style servers share: reachable when | |
| 33 | +// /v1/models answers, `missing` as the warning when the model is not listed, | |
| 34 | +// and the served window from /api/ps when the model is loaded. | |
| 35 | +func probeAPIPS(b Backend, missing string) Info { | |
| 36 | + var list modelList | |
| 37 | + info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | |
| 38 | + if !info.Reachable { | |
| 39 | + return info | |
| 40 | + } | |
| 41 | + if !list.has(b.Model) { | |
| 42 | + info.Warnings = append(info.Warnings, missing) | |
| 43 | + return info | |
| 44 | + } | |
| 45 | + if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | |
| 46 | + info.ContextWindow, info.ContextSource = w, "/api/ps" | |
| 47 | + } | |
| 48 | + return info | |
| 49 | +} | |
| 50 | + | |
| 32 | 51 | // loadedWindow reads /api/ps, one level above /v1, and returns the served |
| 33 | 52 | // context of `model` — 0 when it is not loaded or the route does not answer. |
| 34 | 53 | func loadedWindow(baseURL, model string) int { |
| @@ -29,6 +29,25 @@ func (l modelList) has(id string) bool { | |||
| 29 | return false | 29 | return false |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | +// probeAPIPS is the probe the Ollama-style servers share: reachable when | ||
| 33 | +// /v1/models answers, `missing` as the warning when the model is not listed, | ||
| 34 | +// and the served window from /api/ps when the model is loaded. | ||
| 35 | +func probeAPIPS(b Backend, missing string) Info { | ||
| 36 | + var list modelList | ||
| 37 | + info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | ||
| 38 | + if !info.Reachable { | ||
| 39 | + return info | ||
| 40 | + } | ||
| 41 | + if !list.has(b.Model) { | ||
| 42 | + info.Warnings = append(info.Warnings, missing) | ||
| 43 | + return info | ||
| 44 | + } | ||
| 45 | + if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | ||
| 46 | + info.ContextWindow, info.ContextSource = w, "/api/ps" | ||
| 47 | + } | ||
| 48 | + return info | ||
| 49 | +} | ||
| 50 | + | ||
| 32 | // loadedWindow reads /api/ps, one level above /v1, and returns the served | 51 | // loadedWindow reads /api/ps, one level above /v1, and returns the served |
| 33 | // context of `model` — 0 when it is not loaded or the route does not answer. | 52 | // context of `model` — 0 when it is not loaded or the route does not answer. |
| 34 | func loadedWindow(baseURL, model string) int { | 53 | func loadedWindow(baseURL, model string) int { |
modified
internal/engine/llmman.go +1 -14 | @@ -16,18 +16,5 @@ import ( | ||
| 16 | 16 | // pulls it, which can take minutes — worth a warning, because the wait looks |
| 17 | 17 | // like a stalled server. |
| 18 | 18 | func probeLlmman(_ context.Context, b Backend) Info { |
| 19 | - var list modelList | |
| 20 | - info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | |
| 21 | - if !info.Reachable { | |
| 22 | - return info | |
| 23 | - } | |
| 24 | - if !list.has(b.Model) { | |
| 25 | - info.Warnings = append(info.Warnings, | |
| 26 | - fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model)) | |
| 27 | - return info | |
| 28 | - } | |
| 29 | - if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | |
| 30 | - info.ContextWindow, info.ContextSource = w, "/api/ps" | |
| 31 | - } | |
| 32 | - return info | |
| 19 | + return probeAPIPS(b, fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model)) | |
| 33 | 20 | } |
| @@ -16,18 +16,5 @@ import ( | |||
| 16 | // pulls it, which can take minutes — worth a warning, because the wait looks | 16 | // pulls it, which can take minutes — worth a warning, because the wait looks |
| 17 | // like a stalled server. | 17 | // like a stalled server. |
| 18 | func probeLlmman(_ context.Context, b Backend) Info { | 18 | func probeLlmman(_ context.Context, b Backend) Info { |
| 19 | - var list modelList | 19 | + return probeAPIPS(b, fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model)) |
| 20 | - info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | ||
| 21 | - if !info.Reachable { | ||
| 22 | - return info | ||
| 23 | - } | ||
| 24 | - if !list.has(b.Model) { | ||
| 25 | - info.Warnings = append(info.Warnings, | ||
| 26 | - fmt.Sprintf("llmman: model %q is not in the store — the first question will pull it (or: llmman pull %s)", b.Model, b.Model)) | ||
| 27 | - return info | ||
| 28 | - } | ||
| 29 | - if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | ||
| 30 | - info.ContextWindow, info.ContextSource = w, "/api/ps" | ||
| 31 | - } | ||
| 32 | - return info | ||
| 33 | } | 20 | } |
modified
internal/engine/ollama.go +1 -14 | @@ -10,18 +10,5 @@ import ( | ||
| 10 | 10 | // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull |
| 11 | 11 | // a missing model: the request fails with a 404, so the warning says to pull. |
| 12 | 12 | func probeOllama(_ context.Context, b Backend) Info { |
| 13 | - var list modelList | |
| 14 | - info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | |
| 15 | - if !info.Reachable { | |
| 16 | - return info | |
| 17 | - } | |
| 18 | - if !list.has(b.Model) { | |
| 19 | - info.Warnings = append(info.Warnings, | |
| 20 | - fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model)) | |
| 21 | - return info | |
| 22 | - } | |
| 23 | - if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | |
| 24 | - info.ContextWindow, info.ContextSource = w, "/api/ps" | |
| 25 | - } | |
| 26 | - return info | |
| 13 | + return probeAPIPS(b, fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model)) | |
| 27 | 14 | } |
| @@ -10,18 +10,5 @@ import ( | |||
| 10 | // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull | 10 | // for a loaded qwen3.5:2b). Unlike llmman, Ollama's OpenAI API does NOT pull |
| 11 | // a missing model: the request fails with a 404, so the warning says to pull. | 11 | // a missing model: the request fails with a 404, so the warning says to pull. |
| 12 | func probeOllama(_ context.Context, b Backend) Info { | 12 | func probeOllama(_ context.Context, b Backend) Info { |
| 13 | - var list modelList | 13 | + return probeAPIPS(b, fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model)) |
| 14 | - info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | ||
| 15 | - if !info.Reachable { | ||
| 16 | - return info | ||
| 17 | - } | ||
| 18 | - if !list.has(b.Model) { | ||
| 19 | - info.Warnings = append(info.Warnings, | ||
| 20 | - fmt.Sprintf("ollama: model %q is not in the server's list — ollama pull %s", b.Model, b.Model)) | ||
| 21 | - return info | ||
| 22 | - } | ||
| 23 | - if w := loadedWindow(b.BaseURL, b.Model); w > 0 { | ||
| 24 | - info.ContextWindow, info.ContextSource = w, "/api/ps" | ||
| 25 | - } | ||
| 26 | - return info | ||
| 27 | } | 14 | } |
modified
internal/tools/plan.go +2 -183 | @@ -443,101 +443,7 @@ Always show the user the current plan status using the status action.` | ||
| 443 | 443 | } |
| 444 | 444 | |
| 445 | 445 | spinner.Start("Running") |
| 446 | - | |
| 447 | - var result string | |
| 448 | - | |
| 449 | - switch in.Action { | |
| 450 | - case "create": | |
| 451 | - plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks) | |
| 452 | - if createErr != nil { | |
| 453 | - result = fmt.Sprintf("[error: %v]", createErr) | |
| 454 | - break | |
| 455 | - } | |
| 456 | - // Return plan info as JSON for model, plus text summary | |
| 457 | - output := PlanOutput{ | |
| 458 | - Result: msg, | |
| 459 | - PlanName: plan.Name, | |
| 460 | - Plan: plan, | |
| 461 | - Current: plan.Current, | |
| 462 | - Total: len(plan.Tasks), | |
| 463 | - } | |
| 464 | - outputBytes, _ := json.Marshal(output) | |
| 465 | - result = string(outputBytes) | |
| 466 | - | |
| 467 | - case "get": | |
| 468 | - plan, msg, getErr := planStore.GetPlan(in.Name) | |
| 469 | - if getErr != nil { | |
| 470 | - result = fmt.Sprintf("[error: %v]", getErr) | |
| 471 | - break | |
| 472 | - } | |
| 473 | - output := PlanOutput{ | |
| 474 | - Result: msg, | |
| 475 | - Plan: plan, | |
| 476 | - } | |
| 477 | - outputBytes, _ := json.Marshal(output) | |
| 478 | - result = string(outputBytes) | |
| 479 | - | |
| 480 | - case "update": | |
| 481 | - _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | |
| 482 | - if updateErr != nil { | |
| 483 | - result = fmt.Sprintf("[error: %v]", updateErr) | |
| 484 | - break | |
| 485 | - } | |
| 486 | - // Get status line | |
| 487 | - statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 488 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 489 | - | |
| 490 | - case "next": | |
| 491 | - _, nextTask, msg, nextErr := planStore.NextTask(in.Name) | |
| 492 | - if nextErr != nil { | |
| 493 | - result = fmt.Sprintf("[error: %v]", nextErr) | |
| 494 | - break | |
| 495 | - } | |
| 496 | - // Get status line | |
| 497 | - statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 498 | - if nextTask != nil { | |
| 499 | - result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | |
| 500 | - msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | |
| 501 | - } else { | |
| 502 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 503 | - } | |
| 504 | - | |
| 505 | - case "list": | |
| 506 | - plans, msg, listErr := planStore.ListPlans() | |
| 507 | - if listErr != nil { | |
| 508 | - result = fmt.Sprintf("[error: %v]", listErr) | |
| 509 | - break | |
| 510 | - } | |
| 511 | - if len(plans) == 0 { | |
| 512 | - result = `{"result":"` + msg + `","plans":[]}` | |
| 513 | - break | |
| 514 | - } | |
| 515 | - // Build simple list | |
| 516 | - var planNames []string | |
| 517 | - for _, p := range plans { | |
| 518 | - planNames = append(planNames, p.Name) | |
| 519 | - } | |
| 520 | - result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`)) | |
| 521 | - | |
| 522 | - case "delete": | |
| 523 | - msg, delErr := planStore.DeletePlan(in.Name) | |
| 524 | - if delErr != nil { | |
| 525 | - result = fmt.Sprintf("[error: %v]", delErr) | |
| 526 | - break | |
| 527 | - } | |
| 528 | - result = fmt.Sprintf(`{"result":"%s"}`, msg) | |
| 529 | - | |
| 530 | - case "status": | |
| 531 | - statusLine, msg, statusErr := planStore.GetStatus(in.Name) | |
| 532 | - if statusErr != nil { | |
| 533 | - result = fmt.Sprintf("[error: %v]", statusErr) | |
| 534 | - break | |
| 535 | - } | |
| 536 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 537 | - | |
| 538 | - default: | |
| 539 | - result = fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action) | |
| 540 | - } | |
| 446 | + result := runPlanAction(in, true) | |
| 541 | 447 | |
| 542 | 448 | // Record in loop detector |
| 543 | 449 | inputJSON, _ := json.Marshal(in) |
| @@ -580,94 +486,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl | ||
| 580 | 486 | |
| 581 | 487 | s.ToolRunning(ev.ID) |
| 582 | 488 | |
| 583 | - // Execute the action | |
| 584 | - var result string | |
| 585 | - | |
| 586 | - switch in.Action { | |
| 587 | - case "create": | |
| 588 | - plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks) | |
| 589 | - if createErr != nil { | |
| 590 | - result = fmt.Sprintf("[error: %v]", createErr) | |
| 591 | - } else { | |
| 592 | - output := PlanOutput{ | |
| 593 | - Result: msg, | |
| 594 | - PlanName: plan.Name, | |
| 595 | - Current: plan.Current, | |
| 596 | - Total: len(plan.Tasks), | |
| 597 | - } | |
| 598 | - outputBytes, _ := json.Marshal(output) | |
| 599 | - result = string(outputBytes) | |
| 600 | - } | |
| 601 | - | |
| 602 | - case "get": | |
| 603 | - plan, msg, getErr := planStore.GetPlan(in.Name) | |
| 604 | - if getErr != nil { | |
| 605 | - result = fmt.Sprintf("[error: %v]", getErr) | |
| 606 | - } else { | |
| 607 | - output := PlanOutput{ | |
| 608 | - Result: msg, | |
| 609 | - Plan: plan, | |
| 610 | - } | |
| 611 | - outputBytes, _ := json.Marshal(output) | |
| 612 | - result = string(outputBytes) | |
| 613 | - } | |
| 614 | - | |
| 615 | - case "update": | |
| 616 | - _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | |
| 617 | - if updateErr != nil { | |
| 618 | - result = fmt.Sprintf("[error: %v]", updateErr) | |
| 619 | - } else { | |
| 620 | - statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 621 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 622 | - } | |
| 623 | - | |
| 624 | - case "next": | |
| 625 | - _, nextTask, msg, nextErr := planStore.NextTask(in.Name) | |
| 626 | - if nextErr != nil { | |
| 627 | - result = fmt.Sprintf("[error: %v]", nextErr) | |
| 628 | - } else { | |
| 629 | - statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 630 | - if nextTask != nil { | |
| 631 | - result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | |
| 632 | - msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | |
| 633 | - } else { | |
| 634 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 635 | - } | |
| 636 | - } | |
| 637 | - | |
| 638 | - case "list": | |
| 639 | - plans, msg, listErr := planStore.ListPlans() | |
| 640 | - if listErr != nil { | |
| 641 | - result = fmt.Sprintf("[error: %v]", listErr) | |
| 642 | - } else if len(plans) == 0 { | |
| 643 | - result = `{"result":"` + msg + `","plans":[]}` | |
| 644 | - } else { | |
| 645 | - var planNames []string | |
| 646 | - for _, p := range plans { | |
| 647 | - planNames = append(planNames, p.Name) | |
| 648 | - } | |
| 649 | - result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`)) | |
| 650 | - } | |
| 651 | - | |
| 652 | - case "delete": | |
| 653 | - msg, delErr := planStore.DeletePlan(in.Name) | |
| 654 | - if delErr != nil { | |
| 655 | - result = fmt.Sprintf("[error: %v]", delErr) | |
| 656 | - } else { | |
| 657 | - result = fmt.Sprintf(`{"result":"%s"}`, msg) | |
| 658 | - } | |
| 659 | - | |
| 660 | - case "status": | |
| 661 | - statusLine, msg, statusErr := planStore.GetStatus(in.Name) | |
| 662 | - if statusErr != nil { | |
| 663 | - result = fmt.Sprintf("[error: %v]", statusErr) | |
| 664 | - } else { | |
| 665 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 666 | - } | |
| 667 | - | |
| 668 | - default: | |
| 669 | - result = fmt.Sprintf("[error: unknown action '%s']", in.Action) | |
| 670 | - } | |
| 489 | + result := runPlanAction(in, false) | |
| 671 | 490 | |
| 672 | 491 | // Check if failed |
| 673 | 492 | failed := strings.HasPrefix(result, "[error:") |
| @@ -443,101 +443,7 @@ Always show the user the current plan status using the status action.` | |||
| 443 | } | 443 | } |
| 444 | 444 | ||
| 445 | spinner.Start("Running") | 445 | spinner.Start("Running") |
| 446 | - | 446 | + result := runPlanAction(in, true) |
| 447 | - var result string | ||
| 448 | - | ||
| 449 | - switch in.Action { | ||
| 450 | - case "create": | ||
| 451 | - plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks) | ||
| 452 | - if createErr != nil { | ||
| 453 | - result = fmt.Sprintf("[error: %v]", createErr) | ||
| 454 | - break | ||
| 455 | - } | ||
| 456 | - // Return plan info as JSON for model, plus text summary | ||
| 457 | - output := PlanOutput{ | ||
| 458 | - Result: msg, | ||
| 459 | - PlanName: plan.Name, | ||
| 460 | - Plan: plan, | ||
| 461 | - Current: plan.Current, | ||
| 462 | - Total: len(plan.Tasks), | ||
| 463 | - } | ||
| 464 | - outputBytes, _ := json.Marshal(output) | ||
| 465 | - result = string(outputBytes) | ||
| 466 | - | ||
| 467 | - case "get": | ||
| 468 | - plan, msg, getErr := planStore.GetPlan(in.Name) | ||
| 469 | - if getErr != nil { | ||
| 470 | - result = fmt.Sprintf("[error: %v]", getErr) | ||
| 471 | - break | ||
| 472 | - } | ||
| 473 | - output := PlanOutput{ | ||
| 474 | - Result: msg, | ||
| 475 | - Plan: plan, | ||
| 476 | - } | ||
| 477 | - outputBytes, _ := json.Marshal(output) | ||
| 478 | - result = string(outputBytes) | ||
| 479 | - | ||
| 480 | - case "update": | ||
| 481 | - _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | ||
| 482 | - if updateErr != nil { | ||
| 483 | - result = fmt.Sprintf("[error: %v]", updateErr) | ||
| 484 | - break | ||
| 485 | - } | ||
| 486 | - // Get status line | ||
| 487 | - statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 488 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 489 | - | ||
| 490 | - case "next": | ||
| 491 | - _, nextTask, msg, nextErr := planStore.NextTask(in.Name) | ||
| 492 | - if nextErr != nil { | ||
| 493 | - result = fmt.Sprintf("[error: %v]", nextErr) | ||
| 494 | - break | ||
| 495 | - } | ||
| 496 | - // Get status line | ||
| 497 | - statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 498 | - if nextTask != nil { | ||
| 499 | - result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | ||
| 500 | - msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | ||
| 501 | - } else { | ||
| 502 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 503 | - } | ||
| 504 | - | ||
| 505 | - case "list": | ||
| 506 | - plans, msg, listErr := planStore.ListPlans() | ||
| 507 | - if listErr != nil { | ||
| 508 | - result = fmt.Sprintf("[error: %v]", listErr) | ||
| 509 | - break | ||
| 510 | - } | ||
| 511 | - if len(plans) == 0 { | ||
| 512 | - result = `{"result":"` + msg + `","plans":[]}` | ||
| 513 | - break | ||
| 514 | - } | ||
| 515 | - // Build simple list | ||
| 516 | - var planNames []string | ||
| 517 | - for _, p := range plans { | ||
| 518 | - planNames = append(planNames, p.Name) | ||
| 519 | - } | ||
| 520 | - result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`)) | ||
| 521 | - | ||
| 522 | - case "delete": | ||
| 523 | - msg, delErr := planStore.DeletePlan(in.Name) | ||
| 524 | - if delErr != nil { | ||
| 525 | - result = fmt.Sprintf("[error: %v]", delErr) | ||
| 526 | - break | ||
| 527 | - } | ||
| 528 | - result = fmt.Sprintf(`{"result":"%s"}`, msg) | ||
| 529 | - | ||
| 530 | - case "status": | ||
| 531 | - statusLine, msg, statusErr := planStore.GetStatus(in.Name) | ||
| 532 | - if statusErr != nil { | ||
| 533 | - result = fmt.Sprintf("[error: %v]", statusErr) | ||
| 534 | - break | ||
| 535 | - } | ||
| 536 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 537 | - | ||
| 538 | - default: | ||
| 539 | - result = fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action) | ||
| 540 | - } | ||
| 541 | 447 | ||
| 542 | // Record in loop detector | 448 | // Record in loop detector |
| 543 | inputJSON, _ := json.Marshal(in) | 449 | inputJSON, _ := json.Marshal(in) |
| @@ -580,94 +486,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl | |||
| 580 | 486 | ||
| 581 | s.ToolRunning(ev.ID) | 487 | s.ToolRunning(ev.ID) |
| 582 | 488 | ||
| 583 | - // Execute the action | 489 | + result := runPlanAction(in, false) |
| 584 | - var result string | ||
| 585 | - | ||
| 586 | - switch in.Action { | ||
| 587 | - case "create": | ||
| 588 | - plan, msg, createErr := planStore.CreatePlan(in.Name, in.Tasks) | ||
| 589 | - if createErr != nil { | ||
| 590 | - result = fmt.Sprintf("[error: %v]", createErr) | ||
| 591 | - } else { | ||
| 592 | - output := PlanOutput{ | ||
| 593 | - Result: msg, | ||
| 594 | - PlanName: plan.Name, | ||
| 595 | - Current: plan.Current, | ||
| 596 | - Total: len(plan.Tasks), | ||
| 597 | - } | ||
| 598 | - outputBytes, _ := json.Marshal(output) | ||
| 599 | - result = string(outputBytes) | ||
| 600 | - } | ||
| 601 | - | ||
| 602 | - case "get": | ||
| 603 | - plan, msg, getErr := planStore.GetPlan(in.Name) | ||
| 604 | - if getErr != nil { | ||
| 605 | - result = fmt.Sprintf("[error: %v]", getErr) | ||
| 606 | - } else { | ||
| 607 | - output := PlanOutput{ | ||
| 608 | - Result: msg, | ||
| 609 | - Plan: plan, | ||
| 610 | - } | ||
| 611 | - outputBytes, _ := json.Marshal(output) | ||
| 612 | - result = string(outputBytes) | ||
| 613 | - } | ||
| 614 | - | ||
| 615 | - case "update": | ||
| 616 | - _, msg, updateErr := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | ||
| 617 | - if updateErr != nil { | ||
| 618 | - result = fmt.Sprintf("[error: %v]", updateErr) | ||
| 619 | - } else { | ||
| 620 | - statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 621 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 622 | - } | ||
| 623 | - | ||
| 624 | - case "next": | ||
| 625 | - _, nextTask, msg, nextErr := planStore.NextTask(in.Name) | ||
| 626 | - if nextErr != nil { | ||
| 627 | - result = fmt.Sprintf("[error: %v]", nextErr) | ||
| 628 | - } else { | ||
| 629 | - statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 630 | - if nextTask != nil { | ||
| 631 | - result = fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | ||
| 632 | - msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | ||
| 633 | - } else { | ||
| 634 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 635 | - } | ||
| 636 | - } | ||
| 637 | - | ||
| 638 | - case "list": | ||
| 639 | - plans, msg, listErr := planStore.ListPlans() | ||
| 640 | - if listErr != nil { | ||
| 641 | - result = fmt.Sprintf("[error: %v]", listErr) | ||
| 642 | - } else if len(plans) == 0 { | ||
| 643 | - result = `{"result":"` + msg + `","plans":[]}` | ||
| 644 | - } else { | ||
| 645 | - var planNames []string | ||
| 646 | - for _, p := range plans { | ||
| 647 | - planNames = append(planNames, p.Name) | ||
| 648 | - } | ||
| 649 | - result = fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(planNames, `", "`)) | ||
| 650 | - } | ||
| 651 | - | ||
| 652 | - case "delete": | ||
| 653 | - msg, delErr := planStore.DeletePlan(in.Name) | ||
| 654 | - if delErr != nil { | ||
| 655 | - result = fmt.Sprintf("[error: %v]", delErr) | ||
| 656 | - } else { | ||
| 657 | - result = fmt.Sprintf(`{"result":"%s"}`, msg) | ||
| 658 | - } | ||
| 659 | - | ||
| 660 | - case "status": | ||
| 661 | - statusLine, msg, statusErr := planStore.GetStatus(in.Name) | ||
| 662 | - if statusErr != nil { | ||
| 663 | - result = fmt.Sprintf("[error: %v]", statusErr) | ||
| 664 | - } else { | ||
| 665 | - result = fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 666 | - } | ||
| 667 | - | ||
| 668 | - default: | ||
| 669 | - result = fmt.Sprintf("[error: unknown action '%s']", in.Action) | ||
| 670 | - } | ||
| 671 | 490 | ||
| 672 | // Check if failed | 491 | // Check if failed |
| 673 | failed := strings.HasPrefix(result, "[error:") | 492 | failed := strings.HasPrefix(result, "[error:") |
added
internal/tools/plan_actions.go +115 -0 | new file mode 100644 | ||
| @@ -0,0 +1,115 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "fmt" | |
| 6 | + "strings" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// planActions runs one plan action and renders its result for the model. | |
| 10 | +// "create" is not here: it depends on the front end (see runPlanAction). | |
| 11 | +var planActions = map[string]func(planInput) string{ | |
| 12 | + "get": planGet, | |
| 13 | + "update": planUpdate, | |
| 14 | + "next": planNext, | |
| 15 | + "list": planList, | |
| 16 | + "delete": planDelete, | |
| 17 | + "status": planStatus, | |
| 18 | +} | |
| 19 | + | |
| 20 | +// runPlanAction is the one dispatch both front ends share. withPlan puts the | |
| 21 | +// whole created plan in the "create" answer — the terminal does, the | |
| 22 | +// event-consuming front ends (ACP) only give its name and position. | |
| 23 | +func runPlanAction(in planInput, withPlan bool) string { | |
| 24 | + if in.Action == "create" { | |
| 25 | + return planCreate(in, withPlan) | |
| 26 | + } | |
| 27 | + if run, ok := planActions[in.Action]; ok { | |
| 28 | + return run(in) | |
| 29 | + } | |
| 30 | + return fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action) | |
| 31 | +} | |
| 32 | + | |
| 33 | +// planError is the error answer every action gives. | |
| 34 | +func planError(err error) string { | |
| 35 | + return fmt.Sprintf("[error: %v]", err) | |
| 36 | +} | |
| 37 | + | |
| 38 | +// planJSON renders a PlanOutput. | |
| 39 | +func planJSON(out PlanOutput) string { | |
| 40 | + b, _ := json.Marshal(out) | |
| 41 | + return string(b) | |
| 42 | +} | |
| 43 | + | |
| 44 | +func planCreate(in planInput, withPlan bool) string { | |
| 45 | + plan, msg, err := planStore.CreatePlan(in.Name, in.Tasks) | |
| 46 | + if err != nil { | |
| 47 | + return planError(err) | |
| 48 | + } | |
| 49 | + out := PlanOutput{Result: msg, PlanName: plan.Name, Current: plan.Current, Total: len(plan.Tasks)} | |
| 50 | + if withPlan { | |
| 51 | + out.Plan = plan | |
| 52 | + } | |
| 53 | + return planJSON(out) | |
| 54 | +} | |
| 55 | + | |
| 56 | +func planGet(in planInput) string { | |
| 57 | + plan, msg, err := planStore.GetPlan(in.Name) | |
| 58 | + if err != nil { | |
| 59 | + return planError(err) | |
| 60 | + } | |
| 61 | + return planJSON(PlanOutput{Result: msg, Plan: plan}) | |
| 62 | +} | |
| 63 | + | |
| 64 | +func planUpdate(in planInput) string { | |
| 65 | + _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | |
| 66 | + if err != nil { | |
| 67 | + return planError(err) | |
| 68 | + } | |
| 69 | + statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 70 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 71 | +} | |
| 72 | + | |
| 73 | +func planNext(in planInput) string { | |
| 74 | + _, nextTask, msg, err := planStore.NextTask(in.Name) | |
| 75 | + if err != nil { | |
| 76 | + return planError(err) | |
| 77 | + } | |
| 78 | + statusLine, _, _ := planStore.GetStatus(in.Name) | |
| 79 | + if nextTask == nil { | |
| 80 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 81 | + } | |
| 82 | + return fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | |
| 83 | + msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | |
| 84 | +} | |
| 85 | + | |
| 86 | +func planList(planInput) string { | |
| 87 | + plans, msg, err := planStore.ListPlans() | |
| 88 | + if err != nil { | |
| 89 | + return planError(err) | |
| 90 | + } | |
| 91 | + if len(plans) == 0 { | |
| 92 | + return `{"result":"` + msg + `","plans":[]}` | |
| 93 | + } | |
| 94 | + names := make([]string, 0, len(plans)) | |
| 95 | + for _, p := range plans { | |
| 96 | + names = append(names, p.Name) | |
| 97 | + } | |
| 98 | + return fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(names, `", "`)) | |
| 99 | +} | |
| 100 | + | |
| 101 | +func planDelete(in planInput) string { | |
| 102 | + msg, err := planStore.DeletePlan(in.Name) | |
| 103 | + if err != nil { | |
| 104 | + return planError(err) | |
| 105 | + } | |
| 106 | + return fmt.Sprintf(`{"result":"%s"}`, msg) | |
| 107 | +} | |
| 108 | + | |
| 109 | +func planStatus(in planInput) string { | |
| 110 | + statusLine, msg, err := planStore.GetStatus(in.Name) | |
| 111 | + if err != nil { | |
| 112 | + return planError(err) | |
| 113 | + } | |
| 114 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | |
| 115 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,115 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "fmt" | ||
| 6 | + "strings" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// planActions runs one plan action and renders its result for the model. | ||
| 10 | +// "create" is not here: it depends on the front end (see runPlanAction). | ||
| 11 | +var planActions = map[string]func(planInput) string{ | ||
| 12 | + "get": planGet, | ||
| 13 | + "update": planUpdate, | ||
| 14 | + "next": planNext, | ||
| 15 | + "list": planList, | ||
| 16 | + "delete": planDelete, | ||
| 17 | + "status": planStatus, | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// runPlanAction is the one dispatch both front ends share. withPlan puts the | ||
| 21 | +// whole created plan in the "create" answer — the terminal does, the | ||
| 22 | +// event-consuming front ends (ACP) only give its name and position. | ||
| 23 | +func runPlanAction(in planInput, withPlan bool) string { | ||
| 24 | + if in.Action == "create" { | ||
| 25 | + return planCreate(in, withPlan) | ||
| 26 | + } | ||
| 27 | + if run, ok := planActions[in.Action]; ok { | ||
| 28 | + return run(in) | ||
| 29 | + } | ||
| 30 | + return fmt.Sprintf("[error: unknown action '%s'. Valid actions: create, get, update, next, list, delete, status]", in.Action) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// planError is the error answer every action gives. | ||
| 34 | +func planError(err error) string { | ||
| 35 | + return fmt.Sprintf("[error: %v]", err) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// planJSON renders a PlanOutput. | ||
| 39 | +func planJSON(out PlanOutput) string { | ||
| 40 | + b, _ := json.Marshal(out) | ||
| 41 | + return string(b) | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func planCreate(in planInput, withPlan bool) string { | ||
| 45 | + plan, msg, err := planStore.CreatePlan(in.Name, in.Tasks) | ||
| 46 | + if err != nil { | ||
| 47 | + return planError(err) | ||
| 48 | + } | ||
| 49 | + out := PlanOutput{Result: msg, PlanName: plan.Name, Current: plan.Current, Total: len(plan.Tasks)} | ||
| 50 | + if withPlan { | ||
| 51 | + out.Plan = plan | ||
| 52 | + } | ||
| 53 | + return planJSON(out) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func planGet(in planInput) string { | ||
| 57 | + plan, msg, err := planStore.GetPlan(in.Name) | ||
| 58 | + if err != nil { | ||
| 59 | + return planError(err) | ||
| 60 | + } | ||
| 61 | + return planJSON(PlanOutput{Result: msg, Plan: plan}) | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +func planUpdate(in planInput) string { | ||
| 65 | + _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message) | ||
| 66 | + if err != nil { | ||
| 67 | + return planError(err) | ||
| 68 | + } | ||
| 69 | + statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 70 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func planNext(in planInput) string { | ||
| 74 | + _, nextTask, msg, err := planStore.NextTask(in.Name) | ||
| 75 | + if err != nil { | ||
| 76 | + return planError(err) | ||
| 77 | + } | ||
| 78 | + statusLine, _, _ := planStore.GetStatus(in.Name) | ||
| 79 | + if nextTask == nil { | ||
| 80 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 81 | + } | ||
| 82 | + return fmt.Sprintf(`{"result":"%s","status":"%s","nextTask":{"id":%d,"description":"%s"}}`, | ||
| 83 | + msg, statusLine, nextTask.ID, strings.ReplaceAll(nextTask.Description, "\"", "\\\"")) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +func planList(planInput) string { | ||
| 87 | + plans, msg, err := planStore.ListPlans() | ||
| 88 | + if err != nil { | ||
| 89 | + return planError(err) | ||
| 90 | + } | ||
| 91 | + if len(plans) == 0 { | ||
| 92 | + return `{"result":"` + msg + `","plans":[]}` | ||
| 93 | + } | ||
| 94 | + names := make([]string, 0, len(plans)) | ||
| 95 | + for _, p := range plans { | ||
| 96 | + names = append(names, p.Name) | ||
| 97 | + } | ||
| 98 | + return fmt.Sprintf(`{"result":"%s","plans":["%s"]}`, msg, strings.Join(names, `", "`)) | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +func planDelete(in planInput) string { | ||
| 102 | + msg, err := planStore.DeletePlan(in.Name) | ||
| 103 | + if err != nil { | ||
| 104 | + return planError(err) | ||
| 105 | + } | ||
| 106 | + return fmt.Sprintf(`{"result":"%s"}`, msg) | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +func planStatus(in planInput) string { | ||
| 110 | + statusLine, msg, err := planStore.GetStatus(in.Name) | ||
| 111 | + if err != nil { | ||
| 112 | + return planError(err) | ||
| 113 | + } | ||
| 114 | + return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine) | ||
| 115 | +} | ||
added
internal/tools/plan_actions_test.go +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// runPlanAction answers every action the two front ends used to dispatch | |
| 10 | +// separately: the terminal's "create" carries the whole plan, ACP's does not. | |
| 11 | +func TestRunPlanAction(t *testing.T) { | |
| 12 | + ClearAllPlansForTesting() | |
| 13 | + tasks := []PlanTaskInput{{Description: `say "hi"`}, {Description: "B"}} | |
| 14 | + | |
| 15 | + var full, short PlanOutput | |
| 16 | + if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p1", Tasks: tasks}, true)), &full); err != nil || full.Plan == nil { | |
| 17 | + t.Fatalf("create with plan: %+v, %v", full, err) | |
| 18 | + } | |
| 19 | + if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p2", Tasks: tasks}, false)), &short); err != nil { | |
| 20 | + t.Fatalf("create without plan: %v", err) | |
| 21 | + } | |
| 22 | + if short.Plan != nil || short.PlanName != "p2" || short.Total != 2 { | |
| 23 | + t.Errorf("create without plan: %+v", short) | |
| 24 | + } | |
| 25 | + | |
| 26 | + for _, tc := range []struct { | |
| 27 | + in planInput | |
| 28 | + want string | |
| 29 | + }{ | |
| 30 | + {planInput{Action: "get", Name: "p1"}, `"plan":`}, | |
| 31 | + {planInput{Action: "update", Name: "p1", TaskID: 1, Status: "in_progress"}, `"status":"[1/2]`}, | |
| 32 | + {planInput{Action: "next", Name: "p1"}, `"nextTask":{"id":1,"description":"say \"hi\""}`}, | |
| 33 | + {planInput{Action: "status", Name: "p1"}, `"status":"[1/2]`}, | |
| 34 | + {planInput{Action: "list"}, `"plans":["`}, | |
| 35 | + {planInput{Action: "delete", Name: "p2"}, `{"result":"`}, | |
| 36 | + {planInput{Action: "get", Name: "p2"}, "[error: "}, | |
| 37 | + {planInput{Action: "explode"}, "[error: unknown action 'explode'. Valid actions:"}, | |
| 38 | + } { | |
| 39 | + if got := runPlanAction(tc.in, true); !strings.Contains(got, tc.want) { | |
| 40 | + t.Errorf("%s %s: %s, want it to contain %s", tc.in.Action, tc.in.Name, got, tc.want) | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// runPlanAction answers every action the two front ends used to dispatch | ||
| 10 | +// separately: the terminal's "create" carries the whole plan, ACP's does not. | ||
| 11 | +func TestRunPlanAction(t *testing.T) { | ||
| 12 | + ClearAllPlansForTesting() | ||
| 13 | + tasks := []PlanTaskInput{{Description: `say "hi"`}, {Description: "B"}} | ||
| 14 | + | ||
| 15 | + var full, short PlanOutput | ||
| 16 | + if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p1", Tasks: tasks}, true)), &full); err != nil || full.Plan == nil { | ||
| 17 | + t.Fatalf("create with plan: %+v, %v", full, err) | ||
| 18 | + } | ||
| 19 | + if err := json.Unmarshal([]byte(runPlanAction(planInput{Action: "create", Name: "p2", Tasks: tasks}, false)), &short); err != nil { | ||
| 20 | + t.Fatalf("create without plan: %v", err) | ||
| 21 | + } | ||
| 22 | + if short.Plan != nil || short.PlanName != "p2" || short.Total != 2 { | ||
| 23 | + t.Errorf("create without plan: %+v", short) | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + for _, tc := range []struct { | ||
| 27 | + in planInput | ||
| 28 | + want string | ||
| 29 | + }{ | ||
| 30 | + {planInput{Action: "get", Name: "p1"}, `"plan":`}, | ||
| 31 | + {planInput{Action: "update", Name: "p1", TaskID: 1, Status: "in_progress"}, `"status":"[1/2]`}, | ||
| 32 | + {planInput{Action: "next", Name: "p1"}, `"nextTask":{"id":1,"description":"say \"hi\""}`}, | ||
| 33 | + {planInput{Action: "status", Name: "p1"}, `"status":"[1/2]`}, | ||
| 34 | + {planInput{Action: "list"}, `"plans":["`}, | ||
| 35 | + {planInput{Action: "delete", Name: "p2"}, `{"result":"`}, | ||
| 36 | + {planInput{Action: "get", Name: "p2"}, "[error: "}, | ||
| 37 | + {planInput{Action: "explode"}, "[error: unknown action 'explode'. Valid actions:"}, | ||
| 38 | + } { | ||
| 39 | + if got := runPlanAction(tc.in, true); !strings.Contains(got, tc.want) { | ||
| 40 | + t.Errorf("%s %s: %s, want it to contain %s", tc.in.Action, tc.in.Name, got, tc.want) | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | +} | ||