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:
SivanCola committed 2026-09-03 19:21:26 +08:00
1 parent ba647da572
commit e30b6af572
12 files changed
+313 -30

No files matched your search

+53 -10
View File
@@ -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 {
+7 -7
View File
@@ -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", " ");
+1 -1
View File
@@ -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>;
+2 -1
View File
@@ -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,
+6 -3
View File
@@ -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 });
}
+11 -6
View File
@@ -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
+25
View File
@@ -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)
}
}
-2
View File
@@ -543,8 +543,6 @@ func (c *Controller) managementNotice(trimmed string) bool {
return true
}
c.notice(c.mcpListText())
default:
return false
}
return true
}
+138
View File
@@ -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}
}
+42
View File
@@ -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)
}
})
}
}