Sonnet 5's Flutter Code Keeps Going Rogue — Here's the Constraint System That Stops It
AI-Driven Flutter Engineering: How to Keep AI-Generated Code on Track
Over the past six months, models like Claude Sonnet 5, GPT-4.1, and Gemini 2.5 Pro have seen their scores on SWE-bench and Terminal-Bench skyrocket. Tools like Cursor and Claude Code have also turned "AI writing Flutter code" from a toy into a productive tool. But almost every heavy user runs into the same problem: AI-generated code is getting stronger, but it's also getting easier to "go off track" — you ask it to change a ListTile, and it casually refactors the entire LoginPage; you ask it to fix a bug, and it takes the liberty of swapping out your state management solution.
This isn't the model getting dumber; it's that constraints haven't kept pace with its growing capabilities. This article, based on real-world scenarios using Cursor + Sonnet 5 + Flutter, dissects the root causes of going off track and provides a proven system to prevent it.
1. Four Root Causes of Going Off Track
| Type | Typical Manifestation | Root Cause |
|---|---|---|
| Scope Drift | While changing file A, it casually changes B/C/D, even refactoring unrelated modules | Fuzzy instruction boundaries + Sonnet 5's agentic tendency (Microsoft testing shows 5 is more prone to "incidental optimization" than 4.6) |
| Paradigm Wobble | Switches between Riverpod and Bloc; mixes find.text and find.byKey |
Project conventions are not anchored in a place visible to the AI, or the rules file is too long for the model to read completely |
| Context Poisoning | After round 10, the model starts forgetting "do not rewrite the entire build" and pastes the whole thing again | Long sessions not compacted/restarted in time; early constraints are diluted by subsequent dialogue |
| Self-Check Hallucination | Model claims "ran tests and all passed," but it didn't actually run them or ran the wrong files | Sonnet 5's agentic self-check + noisy terminal output, leading it to mistakenly believe execution was successful |
These four root causes are especially prominent in Flutter projects—deep widget nesting, long build methods, multiple state management options, and generated files (.g.dart) mixed with handwritten files. A moment's inattention and the model oversteps.
2. The Anti-Drift System: From Hard Constraints to Soft Habits
1. Use AGENTS.md + .cursor/rules/*.mdc for a Two-Layer Paradigm Anchor
Many developers cram dozens of rules into a single .cursorrules file. The result: with alwaysApply too long, Sonnet 5's new tokenizer eats 30% of the capacity, and the model never finishes reading it. The correct approach is layered anchoring:
Layer 1: AGENTS.md (project root, no more than 50 lines)
This file is the universal entry point for all AI tools (Cursor, Claude Code, Codex, Windsurf). Just place it in the project root. Put only non-negotiable red lines here:
# Flutter Project Paradigm (Non-negotiable)
- State Management: Riverpod (AsyncNotifier + freezed sealed)
- Prohibited: Mixing Bloc / Provider / setState
- Widget Finding: Must use WidgetKeys constants (`find.byKey(WidgetKeys.xxx)`), `find.text()` is prohibited
- Code Generation: freezed / json_serializable / riverpod_generator, only change annotations, editing *.g.dart is prohibited
- UI changes must @ file+line number, vague instructions like "change the login page" are prohibited
- Prohibited from rewriting the entire build method, only change the specified sub-tree ±5 lines
Layer 2: .cursor/rules/*.mdc (multi-file, loaded on-demand via globs)
This is the multi-file rule system recommended by Cursor 0.45+. Use globs to match different file types; only rules matching the current file are injected:
.cursor/rules/
├── 000-core.mdc # alwaysApply: true, ≤500 chars, general token saving
├── 010-effort.mdc # alwaysApply: true, controls Sonnet 5 thinking
├── 100-flutter-style.mdc # globs: **/*.dart, Flutter coding conventions
├── 101-state.mdc # globs: lib/**, state management paradigm
├── 102-test.mdc # globs: **/*_test.dart, testing paradigm
└── 103-assets.mdc # globs: pubspec.yaml, protects pubspec
Key Point: The total word count for alwaysApply must be kept under 500 characters (Sonnet 5's new tokenizer can make the same text consume 30% more tokens). Anything beyond that should be split into dedicated files using globs. This way, the model reads the core red lines every round without being overwhelmed by a sea of rules.
2. Shift Instructions from "Open Intent" to "Constraint-Based Prompts"
The primary trigger for going off track is overly open instructions. If you just say "help me optimize the login page," the model will naturally "optimize" according to its own understanding—including refactoring routes, adjusting themes, and rewriting state management. The correct approach is to draw boundaries within the instruction:
| ❌ High Drift Risk | ✅ Constraint-Based |
|---|---|
| "Help me change the login page button to rounded corners" | "@lib/pages/login_page.dart L78, change the ElevatedButton's shape to RoundedRectangleBorder(borderRadius: 12), only change this one widget, do not change the rest of build, do not change AuthBloc" |
| "Add error handling to network requests" | "In the login notifier's signInWithPassword, catch DioException, emit LoginState.error(message), do not add new providers, do not change UI" |
| "Run the tests" | "Run flutter test test/features/login/, only fix failing cases, do not add new tests, do not touch test/shared/ " |
Three elements are indispensable: Where to change (@file+L#) + What to change + What NOT to change. The "what not to change" is often more important than the "what to change"—Sonnet 5's agentic nature dictates that if you don't draw a boundary, it will cross it.
3. For Complex Tasks, Plan First, Then Execute
For tasks like cross-file refactoring, new features, or Router structure adjustments, don't let the model write code directly. In Cursor Composer or Claude Code, have it do this first:
"First, produce a change plan: list the files to be modified, the scope of changes for each file, and the verification commands involved. I will confirm before execution."
During the Plan phase, you only need to watch three things:
- Whether the files involved really need changing (the model often lists 8 files when only 3 are needed; cut the extras)
- Whether it secretly added "incidental optimization" items ("incidentally extract Theme," "incidentally add a golden test"—cut them all)
- Whether the verification commands are correct (the scope of
flutter test, whether build_runner needs to run)
A round of planning usually costs only 200-300 tokens but can save 3-5 rounds of rework later. Planning isn't wasting tokens; it's buying insurance.
4. Session Management: One Task Per Session, Compact/Restart Promptly
The high-risk period for going off track is rounds 10-20—early constraints are diluted by subsequent dialogue, and the model starts "freestyling."
- One session, one task: Fixing login, changing UI, writing tests—three separate sessions.
- Watch the context ring: When the color ring on the left side of the Cursor chat box turns yellow, it's time to start a new Chat. Don't push through.
- Use Checkpoint rollback: If Composer goes astray, roll back to the previous checkpoint directly. Don't continue questioning on a wrong foundation—questioning only stacks tokens and amplifies drift.
- /compact with guidance: In Claude Code, when compacting, add a line like "retain WidgetKeys conventions and Riverpod paradigm, discard specific lines changed," otherwise the red lines might be compressed away after compaction.
5. Two Flutter-Specific Anti-Drift Hooks
WidgetKeys as Constants
The most common drift in Flutter testing and UI is the model oscillating between find.text('Login') and find.byKey(WidgetKeys.loginSubmit). The solution is to maintain key constants uniformly under lib/core/keys/:
abstract class LoginKeys {
static const emailField = Key('login_email_field');
static const passwordField = Key('login_password_field');
static const submit = Key('login_submit');
}
Then, hardcode in 100-flutter-style.mdc: "New interactive elements must add WidgetKeys; changes to existing finds must use WidgetKeys.xxx." Once the model sees a constant reference, it no longer dares to arbitrarily change it to find.text.
The build_runner Moat
In Flutter projects, the model hand-writing .g.dart files is a disaster—it thinks it's "saving you a step," but the result is hand-written generated code that doesn't match the annotations, causing compilation failures. Hardcode in AGENTS.md:
"When changing a model, only change annotations. After changing, prompt the developer to run
flutter pub run build_runner build --delete-conflicting-outputs. Hand-writing *.g.dart is prohibited."
Also, exclude *.g.dart, *.freezed.dart, *.config.dart in .cursorignore so the model can't even read these files, fundamentally preventing hand-writing.
3. Early Signals of Drift: Nip It in the First Round
Don't wait until round 5 to realize the direction is wrong. If any of the following appear in the model's first-round reply, interrupt immediately and re-issue the instruction:
- Starts rephrasing your request ("Okay, I'll help you optimize the login page...") → It's making small talk, and likely about to overstep its authority.
- Starts changing files you didn't @ → Scope drift, stop it immediately: "Only change the @ file."
- Starts writing tests / docs / README you didn't ask for → Agentic self-check overflow, check if the rule "do not proactively run tests after writing" is effective in your rules.
- Starts refactoring unrelated code ("Incidentally, let's extract the Theme too") → A classic Sonnet 5 ailment; the instruction lacked "do not change the rest of the UI."
- The reply has long thinking_blocks but a vague conclusion → An L1 task shouldn't trigger thinking; check if 010-effort.mdc is suppressing it.
4. Summary
In AI-driven Flutter engineering, the core contradiction is not "can the model write it," but "can the model write ONLY what you told it to write." Preventing drift doesn't rely on "smarter models," but on a set of enforceable constraint systems:
| Level | Tool | Function |
|---|---|---|
| Red Line Anchor | AGENTS.md (<50 lines) |
Cross-tool universal, non-negotiable |
| Layered Details | .cursor/rules/*.mdc (globs on-demand loading) |
Injected by file type, alwaysApply ≤500 chars |
| Instruction Design | Constraint-based prompt (Where + What + What NOT) | Draw boundaries for the model |
| Task Workflow | Plan → Confirm → Execute | Align on complex tasks first |
| Session Hygiene | One task per session + prompt compact/restart | Prevent constraint dilution |
| Flutter Specialization | WidgetKeys constants + build_runner moat | Plug the two highest-frequency drift points |
This system has been validated across multiple Flutter production projects: with the same Cursor Pro $20 quota, it used to run out in half a month; after configuration, it lasts a full month, and code quality is noticeably improved—not because the model got stronger, but because it finally knows what it shouldn't do.
The future of AI coding is not "an all-powerful model," but "a model that is all-powerful within the circle you draw." Draw the circle well, and it won't go off track.