mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
fix(desktop): classify management submits before turn admission
Problem: management slash commands were submitted through the durable turn receipt API, which requires a conversational turn id and surfaced false send failures. Root cause: the frontend decided locally which commands bypassed StartTurnForTab, while the controller already owned conditional slash-command semantics. Fix: add a controller-owned submit disposition, return management_handled receipts without a turn id, settle optimistic management bubbles safely, and preserve the legacy SubmitToTabWithID contract. Add route and runtime regression coverage.
This commit is contained in:
1 parent
ba647da572
commit
e30b6af572
12 files changed
+313
-30
No files matched your search
+53
-10
@@ -1080,44 +1080,87 @@ func (a *App) SubmitToTab(tabID, input string) error {
|
||||
// by the IM takeover bridge; local (frontend) submissions on a taken-over tab
|
||||
// reclaim remote control first — typing locally is the grab-back gesture.
|
||||
func (a *App) submitToTab(tabID, input string, fromBridge bool, submissionID ...string) error {
|
||||
_, err := a.submitToTabResult(tabID, input, fromBridge, false, submissionID...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) submitToTabResult(tabID, input string, fromBridge, classifyManagement bool, submissionID ...string) (control.SubmitResult, error) {
|
||||
management := control.SubmitResult{Disposition: control.SubmitManagementHandled}
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "/reload" {
|
||||
tab, _ := a.tabAndCtrlByID(tabID)
|
||||
if a.tabIsReadOnly(tab) {
|
||||
return readOnlyChannelErr()
|
||||
return control.SubmitResult{}, readOnlyChannelErr()
|
||||
}
|
||||
if tab == nil {
|
||||
return a.workspaceNotReadyErr(tab)
|
||||
return control.SubmitResult{}, a.workspaceNotReadyErr(tab)
|
||||
}
|
||||
if !fromBridge && a.botBridge != nil {
|
||||
a.botBridge.reclaimFromDesktop(tab.ID)
|
||||
}
|
||||
return a.ReloadRuntime(tab.ID)
|
||||
return management, a.ReloadRuntime(tab.ID)
|
||||
}
|
||||
if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
|
||||
tab, _ := a.tabAndCtrlByID(tabID)
|
||||
if a.tabIsReadOnly(tab) {
|
||||
return readOnlyChannelErr()
|
||||
return control.SubmitResult{}, readOnlyChannelErr()
|
||||
}
|
||||
if tab == nil {
|
||||
return a.workspaceNotReadyErr(tab)
|
||||
return control.SubmitResult{}, a.workspaceNotReadyErr(tab)
|
||||
}
|
||||
if !fromBridge && a.botBridge != nil {
|
||||
a.botBridge.reclaimFromDesktop(tab.ID)
|
||||
}
|
||||
a.runEffortCommandForTab(tabID, trimmed)
|
||||
return nil
|
||||
return management, nil
|
||||
}
|
||||
tab, ctrl := a.tabAndCtrlByID(tabID)
|
||||
if a.tabIsReadOnly(tab) {
|
||||
return control.SubmitResult{}, readOnlyChannelErr()
|
||||
}
|
||||
if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
|
||||
return control.SubmitResult{}, err
|
||||
}
|
||||
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
||||
return control.SubmitResult{}, err
|
||||
}
|
||||
ctrl = a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
return control.SubmitResult{}, a.workspaceNotReadyErr(tab)
|
||||
}
|
||||
if classifyManagement {
|
||||
if classifier, ok := ctrl.(interface {
|
||||
ClassifySubmitRoute(input string) control.SubmitDisposition
|
||||
}); ok && classifier.ClassifySubmitRoute(input) == control.SubmitManagementHandled {
|
||||
if !fromBridge && a.botBridge != nil {
|
||||
a.botBridge.reclaimFromDesktop(tab.ID)
|
||||
}
|
||||
if submitter, ok := ctrl.(interface {
|
||||
SubmitDisplayWithResult(display, input string) control.SubmitResult
|
||||
}); ok {
|
||||
return submitter.SubmitDisplayWithResult(input, input), nil
|
||||
}
|
||||
ctrl.SubmitDisplay(input, input)
|
||||
return management, nil
|
||||
}
|
||||
}
|
||||
admission, ctrl, err := a.beginTabTurn(tabID, !fromBridge, submissionID...)
|
||||
if err != nil {
|
||||
return err
|
||||
return control.SubmitResult{}, err
|
||||
}
|
||||
defer admission.abort()
|
||||
tab := admission.tab
|
||||
tab = admission.tab
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitDisplay(input, input)
|
||||
result := control.SubmitResult{Disposition: control.SubmitTurnStarted}
|
||||
if submitter, ok := ctrl.(interface {
|
||||
SubmitDisplayWithResult(display, input string) control.SubmitResult
|
||||
}); ok {
|
||||
result = submitter.SubmitDisplayWithResult(input, input)
|
||||
} else {
|
||||
ctrl.SubmitDisplay(input, input)
|
||||
}
|
||||
admission.finish(ctrl)
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
|
||||
|
||||
@@ -1014,6 +1014,7 @@ export default function App() {
|
||||
clearGoal: clearControllerGoal,
|
||||
clearGoalForTab: clearControllerGoalForTab,
|
||||
clearSession,
|
||||
newSession,
|
||||
listSessions,
|
||||
listTrashedSessions,
|
||||
resumeSession,
|
||||
@@ -2205,13 +2206,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId;
|
||||
}, [activeTabId]);
|
||||
|
||||
// handleSend intercepts slash commands that need a desktop-native action before
|
||||
// they reach the backend: "/model <ref>" rebuilds on that model, "/memory"
|
||||
// opens Settings, and "/clear" shows an in-app confirmation card. Everything else — skills (/init, …),
|
||||
// custom commands, bare /model and the other read-only management verbs
|
||||
// (/skill, /hooks, /mcp) — goes straight to Submit, which the controller
|
||||
// resolves (a turn, or a listing Notice).
|
||||
// handleSend reserves only commands that need a desktop-native UI action.
|
||||
const handleSend = useCallback(
|
||||
async (displayText: string, submitText = displayText, requestedTabId = activeTabId, structured?: StructuredInvocationSubmit) => {
|
||||
const sourceTabId = requestedTabId || activeTabId;
|
||||
@@ -2243,6 +2238,11 @@ export default function App() {
|
||||
setClearContextPending(true);
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/new") {
|
||||
if (activeTabIdRef.current !== sourceTabId) return;
|
||||
await newSession();
|
||||
return;
|
||||
}
|
||||
const decisionMock = typeof window !== "undefined" && !window.runtime
|
||||
? decisionSurfaceMockFromInput(trimmed)
|
||||
: null;
|
||||
|
||||
@@ -121,6 +121,11 @@ eq(isLocalRuntimeCommand("/compact"), true, "/compact remains a management comma
|
||||
eq(isLocalRuntimeCommand("/compact preserve the key decisions"), true, "focused /compact remains a management command without a turn receipt");
|
||||
eq(isLocalRuntimeCommand("/reload now"), false, "non-command /reload text still starts an agent turn");
|
||||
eq(isLocalRuntimeCommand("/compactly"), false, "similarly-prefixed text still starts an agent turn");
|
||||
const managementPending = reducer(reducer(initialState, {
|
||||
type: "user", text: "/context", seq: 0, submissionId: "management-1",
|
||||
}), { type: "management_confirmed", submissionId: "management-1" });
|
||||
eq(managementPending.items.some((item) => item.kind === "user" && item.text === "/context"), false, "handled management commands do not remain as conversation turns");
|
||||
eq(managementPending.running, false, "handled management commands release the composer");
|
||||
let rejectedVisibleOnlySubmit = false;
|
||||
try {
|
||||
normalizeTurnSubmit("visible prompt", " ");
|
||||
|
||||
@@ -209,7 +209,7 @@ export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganiza
|
||||
Submit(input: string): Promise<void>;
|
||||
SubmitToTab(tabID: string, input: string): Promise<void>;
|
||||
SubmitToTabWithID(tabID: string, input: string, submissionID: string): Promise<void>;
|
||||
StartTurnForTab?(tabID: string, input: string, submissionID: string): Promise<{ turnId: string; status: string; runtimeEpoch?: string; submissionId?: string }>;
|
||||
StartTurnForTab?(tabID: string, input: string, submissionID: string): Promise<{ turnId: string; status: string; disposition?: "turn_started" | "management_handled"; runtimeEpoch?: string; submissionId?: string }>;
|
||||
SubmitDisplay(display: string, input: string): Promise<void>;
|
||||
SubmitDisplayToTab(tabID: string, display: string, input: string): Promise<void>;
|
||||
SubmitDisplayToTabWithID(tabID: string, display: string, input: string, submissionID: string): Promise<void>;
|
||||
|
||||
@@ -21,7 +21,8 @@ export function normalizeTurnSubmit(displayText: string, submitText: string) {
|
||||
return { display, submit };
|
||||
}
|
||||
|
||||
// Management commands do not create an agent turn or receive a turn id.
|
||||
// Legacy fallback for hosts that predate the structured StartTurnForTab
|
||||
// disposition. Current hosts classify every submit on the backend.
|
||||
export function isLocalRuntimeCommand(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return trimmed === "/reload"
|
||||
|
||||
@@ -41,6 +41,29 @@ export function reduceSubmitFailure(
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceManagementConfirmation(state: State, submissionId: string, observedAt: number): State {
|
||||
if (state.pendingSubmissionId !== submissionId) return state;
|
||||
const preserveRuntime = Boolean(state.activeTurnId || state.live || state.approval || state.ask || state.mcpInteraction);
|
||||
return {
|
||||
...state,
|
||||
items: state.items.filter((item) => !(item.kind === "user" && item.submissionId === submissionId)),
|
||||
pendingUser: undefined,
|
||||
pendingSubmissionId: undefined,
|
||||
running: preserveRuntime,
|
||||
turnActive: preserveRuntime,
|
||||
pendingPrompt: preserveRuntime && Boolean(state.approval || state.ask || state.mcpInteraction),
|
||||
cancelRequested: false,
|
||||
cancellable: preserveRuntime ? state.cancellable : false,
|
||||
activeTurnId: preserveRuntime ? state.activeTurnId : undefined,
|
||||
currentAssistant: preserveRuntime ? state.currentAssistant : undefined,
|
||||
assistantSegmentOrdinal: preserveRuntime ? state.assistantSegmentOrdinal : 0,
|
||||
live: preserveRuntime ? state.live : undefined,
|
||||
streamAttemptJournal: preserveRuntime ? state.streamAttemptJournal : undefined,
|
||||
deliveryRecoveryActive: preserveRuntime && state.deliveryRecoveryActive,
|
||||
turnLifecycleObservedAt: observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTabAfterSubmitFailure(
|
||||
binding: Pick<AppBindings, "ListTabs">,
|
||||
tabId: string,
|
||||
|
||||
@@ -12,8 +12,8 @@ import { settleForkConversationForTab } from "./forkWorktree";
|
||||
import type { MessageActionScope, MessageActionState } from "./messageActions";
|
||||
import { mergeRateBand, type AggregatedRateBand } from "./costRateBand";
|
||||
import { requestInboxCancel, type CancelOutcome } from "./inboxCancel";
|
||||
import { answerPromptForActiveTurn, isLocalRuntimeCommand, normalizeTurnSubmit, resolveActiveTurnId } from "./inboxSubmit";
|
||||
import { findTabAfterSubmitFailure, reduceSubmitFailure } from "./turnSubmissionFailure";
|
||||
import { answerPromptForActiveTurn, normalizeTurnSubmit, resolveActiveTurnId } from "./inboxSubmit";
|
||||
import { findTabAfterSubmitFailure, reduceManagementConfirmation, reduceSubmitFailure } from "./turnSubmissionFailure";
|
||||
import { formatContextMaintenanceNotice, isNewMaintenanceOperation, rememberMaintenanceOperation } from "./contextMaintenanceTypes";
|
||||
import { formatGuardianAssessmentNotice } from "./guardianEvents";
|
||||
import { completionSummaryPresentation, normalizeCompletionSummary, sessionQualityFloor } from "./completionSummary";
|
||||
@@ -783,6 +783,7 @@ type Action =
|
||||
| { type: "user"; text: string; submitText?: string; seq: number; submissionId: string; deliveryRecovery?: boolean }
|
||||
| { type: "unsend" }
|
||||
| { type: "send_confirmed"; submissionId: string }
|
||||
| { type: "management_confirmed"; submissionId: string }
|
||||
| { type: "turn_admitted"; turnId: string; submissionId: string }
|
||||
| { type: "turn_submit_rejected"; submissionId: string; error: string }
|
||||
| { type: "send_failed"; submissionId: string; error: string }
|
||||
@@ -2067,6 +2068,7 @@ export function reducer(s: State, a: Action): State {
|
||||
});
|
||||
}
|
||||
case "send_confirmed": return confirmPendingUser(s, a.submissionId);
|
||||
case "management_confirmed": return reduceManagementConfirmation(s, a.submissionId, promptEventClock());
|
||||
case "turn_admitted":
|
||||
return s.pendingSubmissionId === a.submissionId && a.turnId
|
||||
? { ...s, activeTurnId: a.turnId }
|
||||
@@ -3738,7 +3740,7 @@ export function useController() {
|
||||
? app.SubmitEditedDisplayToTabWithID(tabId, display, submit, original, submissionId)
|
||||
: display !== submit
|
||||
? app.SubmitDisplayToTabWithID(tabId, display, submit, submissionId)
|
||||
: typeof app.StartTurnForTab === "function" && !isLocalRuntimeCommand(submit)
|
||||
: typeof app.StartTurnForTab === "function"
|
||||
? app.StartTurnForTab(tabId, submit, submissionId)
|
||||
: app.SubmitToTabWithID(tabId, submit, submissionId);
|
||||
if (initialGoal) {
|
||||
@@ -3750,6 +3752,7 @@ export function useController() {
|
||||
}
|
||||
void submitPromise.then(
|
||||
(receipt) => {
|
||||
if (receipt && typeof receipt === "object" && "disposition" in receipt && receipt.disposition === "management_handled") return void dispatchTo(tabId, { type: "management_confirmed", submissionId });
|
||||
if (receipt && typeof receipt === "object" && "turnId" in receipt && typeof receipt.turnId === "string") {
|
||||
dispatchTo(tabId, { type: "turn_admitted", turnId: receipt.turnId, submissionId });
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
// TurnStartView is the synchronous admission receipt for the new Wails turn
|
||||
// API. Events remain the streaming authority after admission.
|
||||
type TurnStartView struct {
|
||||
TurnID string `json:"turnId"`
|
||||
Status event.TurnStatus `json:"status"`
|
||||
RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
|
||||
SubmissionID string `json:"submissionId,omitempty"`
|
||||
TurnID string `json:"turnId"`
|
||||
Status event.TurnStatus `json:"status"`
|
||||
Disposition control.SubmitDisposition `json:"disposition"`
|
||||
RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
|
||||
SubmissionID string `json:"submissionId,omitempty"`
|
||||
}
|
||||
|
||||
// StartTurnForTab is the turn-id-aware replacement for SubmitToTab. Existing
|
||||
@@ -24,9 +25,13 @@ func (a *App) StartTurnForTab(tabID, input, submissionID string) (TurnStartView,
|
||||
if strings.TrimSpace(submissionID) == "" {
|
||||
return TurnStartView{}, fmt.Errorf("submissionId is required")
|
||||
}
|
||||
if err := a.SubmitToTabWithID(tabID, input, submissionID); err != nil {
|
||||
result, err := a.submitToTabResult(tabID, input, false, true, submissionID)
|
||||
if err != nil {
|
||||
return TurnStartView{}, err
|
||||
}
|
||||
if result.Disposition == control.SubmitManagementHandled {
|
||||
return TurnStartView{Disposition: result.Disposition, SubmissionID: submissionID}, nil
|
||||
}
|
||||
tab, ctrl := a.tabAndCtrlByID(tabID)
|
||||
if ctrl == nil {
|
||||
return TurnStartView{}, a.workspaceNotReadyErr(tab)
|
||||
@@ -45,7 +50,7 @@ func (a *App) StartTurnForTab(tabID, input, submissionID string) (TurnStartView,
|
||||
// This is an admission receipt, not a potentially raced runtime snapshot.
|
||||
// Ordered events carry every later transition, including a provider that
|
||||
// completed before the Wails Promise was delivered.
|
||||
return TurnStartView{TurnID: turnID, Status: event.TurnQueued, RuntimeEpoch: epoch, SubmissionID: submissionID}, nil
|
||||
return TurnStartView{TurnID: turnID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted, RuntimeEpoch: epoch, SubmissionID: submissionID}, nil
|
||||
}
|
||||
|
||||
// InterruptTurnForTab cancels only the exact active turn. A stale Stop button
|
||||
|
||||
@@ -101,3 +101,28 @@ func TestTurnRuntimeAPIRoutesStopAnswerAndReplayByExactTurn(t *testing.T) {
|
||||
t.Fatalf("empty replay events = %#v, want []", empty.Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTurnForTabReturnsManagementDispositionWithoutTurnID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Sink: sink})
|
||||
t.Cleanup(ctrl.Close)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
|
||||
sink.app = app
|
||||
|
||||
start, err := app.StartTurnForTab(tab.ID, "/context", "submission-management")
|
||||
if err != nil {
|
||||
t.Fatalf("StartTurnForTab management command: %v", err)
|
||||
}
|
||||
if start.Disposition != control.SubmitManagementHandled || start.TurnID != "" {
|
||||
t.Fatalf("management receipt = %+v, want management_handled without turn id", start)
|
||||
}
|
||||
replay, err := app.TurnEventsForTab(tab.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("TurnEventsForTab: %v", err)
|
||||
}
|
||||
if len(replay.Events) != 0 {
|
||||
t.Fatalf("management command created durable turn events: %+v", replay.Events)
|
||||
}
|
||||
}
|
||||
@@ -543,8 +543,6 @@ func (c *Controller) managementNotice(trimmed string) bool {
|
||||
return true
|
||||
}
|
||||
c.notice(c.mcpListText())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/skill"
|
||||
)
|
||||
|
||||
var managementNoticeCommands = map[string]struct{}{
|
||||
"/model": {}, "/provider": {}, "/memory": {}, "/migrate": {}, "/migration": {},
|
||||
"/skill": {}, "/skills": {}, "/plugin": {}, "/plugins": {}, "/reload-cmd": {},
|
||||
"/hooks": {}, "/mcp": {},
|
||||
}
|
||||
|
||||
// SubmitDisposition describes whether a raw submit is expected to create a
|
||||
// durable conversational turn. Management commands can still mutate session
|
||||
// state or emit notices, but they must not be presented as turn admission.
|
||||
type SubmitDisposition string
|
||||
|
||||
const (
|
||||
SubmitTurnStarted SubmitDisposition = "turn_started"
|
||||
SubmitManagementHandled SubmitDisposition = "management_handled"
|
||||
)
|
||||
|
||||
// ClassifySubmitRoute is the single controller-owned route classifier shared
|
||||
// by desktop's turn receipt and the normal Submit path. Keep conditional
|
||||
// commands here so a management-only form never gets mistaken for a turn.
|
||||
func (c *Controller) ClassifySubmitRoute(input string) SubmitDisposition {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if isSimpleManagementInput(trimmed) {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
return c.classifySlashSubmit(trimmed)
|
||||
}
|
||||
|
||||
func isSimpleManagementInput(trimmed string) bool {
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := MemoryQuickAddNote(trimmed); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := RememberCommandNote(trimmed); ok {
|
||||
return true
|
||||
}
|
||||
if goal, ok := ParseGoalCommand(trimmed); ok {
|
||||
return goal.Action != GoalCommandSet
|
||||
}
|
||||
if trimmed == "/reload" || trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
|
||||
return true
|
||||
}
|
||||
switch trimmed {
|
||||
case "/compact", "/context", "/new", "/clear":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) classifySlashSubmit(trimmed string) SubmitDisposition {
|
||||
if _, ok := ParseFinalReadinessRecoveryCommand(trimmed); ok || !strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "!") {
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/mcp__") || c.isPathSubmit(trimmed) {
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) == 0 {
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
switch fields[0] {
|
||||
case "/tree", "/branch", "/switch", "/rewind":
|
||||
return SubmitManagementHandled
|
||||
case "/plan-exec":
|
||||
return c.classifyPlanExecSubmit()
|
||||
case "/prometheus":
|
||||
return classifyPrometheusSubmit(trimmed, fields[0])
|
||||
}
|
||||
if _, ok := managementNoticeCommands[fields[0]]; ok {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
if IsBuiltinDocsSlash(fields[0], c.Commands(), c.SlashSkills()) {
|
||||
return classifyDocsSubmit(trimmed, fields[0])
|
||||
}
|
||||
if sk, task, ok := c.resolveSkillInvocation(trimmed); ok && sk.RunAs == skill.RunSubagent && strings.TrimSpace(task) == "" {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
|
||||
func (c *Controller) isPathSubmit(trimmed string) bool {
|
||||
if _, ok := FileRefLine(trimmed); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := SlashPathLineRef(trimmed, c.workspaceRoot); ok {
|
||||
return true
|
||||
}
|
||||
return SlashPathLikeLine(trimmed)
|
||||
}
|
||||
|
||||
func (c *Controller) classifyPlanExecSubmit() SubmitDisposition {
|
||||
if c.executor == nil || len(c.executor.CanonicalTodoState()) == 0 {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
|
||||
func classifyPrometheusSubmit(trimmed, command string) SubmitDisposition {
|
||||
args := strings.TrimSpace(strings.TrimPrefix(trimmed, command))
|
||||
if args == "" || args == "--strict" {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
if strings.HasPrefix(args, "--strict ") && strings.TrimSpace(strings.TrimPrefix(args, "--strict ")) == "" {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
|
||||
func classifyDocsSubmit(trimmed, command string) SubmitDisposition {
|
||||
if strings.TrimSpace(strings.TrimPrefix(trimmed, command)) == "" {
|
||||
return SubmitManagementHandled
|
||||
}
|
||||
return SubmitTurnStarted
|
||||
}
|
||||
|
||||
// SubmitResult is returned by the result-capable desktop submit path. The
|
||||
// legacy Submit* methods intentionally keep their void/error contracts.
|
||||
type SubmitResult struct {
|
||||
Disposition SubmitDisposition `json:"disposition"`
|
||||
}
|
||||
|
||||
// SubmitDisplayWithResult preserves the existing asynchronous submit behavior
|
||||
// while exposing the route disposition to hosts that need an admission receipt.
|
||||
func (c *Controller) SubmitDisplayWithResult(display, input string) SubmitResult {
|
||||
disposition := c.ClassifySubmitRoute(input)
|
||||
c.SubmitDisplay(display, input)
|
||||
return SubmitResult{Disposition: disposition}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/command"
|
||||
)
|
||||
|
||||
func TestClassifySubmitRouteSeparatesManagementFromTurns(t *testing.T) {
|
||||
c := New(Options{Commands: []command.Command{{Name: "review", Body: "review: $ARGUMENTS"}}})
|
||||
tests := []struct {
|
||||
input string
|
||||
want SubmitDisposition
|
||||
}{
|
||||
{input: "/compact", want: SubmitManagementHandled},
|
||||
{input: "/new", want: SubmitManagementHandled},
|
||||
{input: "/context", want: SubmitManagementHandled},
|
||||
{input: "/model", want: SubmitManagementHandled},
|
||||
{input: "/provider deepseek", want: SubmitManagementHandled},
|
||||
{input: "/memory recall", want: SubmitManagementHandled},
|
||||
{input: "/remember keep this", want: SubmitManagementHandled},
|
||||
{input: "# keep this", want: SubmitManagementHandled},
|
||||
{input: "/goal status", want: SubmitManagementHandled},
|
||||
{input: "/goal pause", want: SubmitManagementHandled},
|
||||
{input: "/docs", want: SubmitManagementHandled},
|
||||
{input: "/plan-exec", want: SubmitManagementHandled},
|
||||
{input: "/prometheus", want: SubmitManagementHandled},
|
||||
{input: "/compactly", want: SubmitTurnStarted},
|
||||
{input: "review the diff", want: SubmitTurnStarted},
|
||||
{input: "/goal ship the fix", want: SubmitTurnStarted},
|
||||
{input: "/docs explain compaction", want: SubmitTurnStarted},
|
||||
{input: "/prometheus ship the fix", want: SubmitTurnStarted},
|
||||
{input: "/review the diff", want: SubmitTurnStarted},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := c.ClassifySubmitRoute(tt.input); got != tt.want {
|
||||
t.Fatalf("ClassifySubmitRoute(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user