Skip to content

docs(agent): pistes d'amélioration de l'agent d'édition, mesurées - #217

Open
EtienneLescot wants to merge 11 commits into
mainfrom
claude/agent-improvement-leads
Open

docs(agent): pistes d'amélioration de l'agent d'édition, mesurées#217
EtienneLescot wants to merge 11 commits into
mainfrom
claude/agent-improvement-leads

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Pistes d'amélioration de l'agent d'édition, appuyées sur les mesures du workbench. Rien à fusionner ici — cette PR est un document de travail, à traiter plus tard.

Chaque piste porte la mesure qui la justifie et, quand elle existe, la contre-mesure qui la départagera. L'ordre est celui où je les traiterais.


1. Le poids du track fait échouer un tour sur deux

Mesuré. Sur la prise réelle de 66 s, getCursorTrack rend 356 points pour 24 238 caractères. La requête suivante passe à ~45 000 caractères. Sur 5 répétitions du prompt wizard, 3 ont expiré à 120 s, toujours au même endroit : juste après l'appel à l'outil. Les 2 qui aboutissent produisent un montage correct.

Ce n'est pas un défaut du modèle : lui donner la donnée le fait échouer.

Pistes, de la moins à la plus intrusive :

  • Supprimer virtualSec quand il est égal à atSec. 28 % du payload, strictement redondant tant qu'aucune coupe n'existe. Un champ virtualEqualsSource: true en tête suffirait. Gain immédiat, aucune perte d'information.
  • Relever le timeout du banc. Ne corrige rien, mais évite de confondre lenteur et refus.
  • Baisser la résolution à 2–3 Hz. À tester après les deux précédentes, jamais avant : ça change ce que le modèle voit, donc on ne saurait plus attribuer une amélioration à la place gagnée ou à la lisibilité.

Le plafond de buildCursorTrack est par ailleurs mou : DEFAULT_MAX_TRACK_POINTS borne la grille, mais les points gardés pour un changement de forme s'ajoutent par-dessus sans que truncated le signale. Ici 356 pour 400, sans conséquence — une capture riche en changements de pointeur dépasserait silencieusement.

2. Le modèle place ses zooms d'après le transcript, pas d'après la trajectoire

Mesuré. Il appelle bien getCursorTrack. Mais en comparant le focus qu'il choisit à la position réelle du curseur dans sa propre fenêtre de zoom : 7 sur 9 sont faux, trois de plus d'un tiers d'image. Le pire vise (0.33, 0.09) — haut de l'écran — quand le curseur est à (0.38, 0.60).

Son récit le trahit : il annonce un zoom sur « Iceman, Views » cinq secondes avant que ces mots soient prononcés. Il raconte une lecture de la trajectoire qu'il n'a pas faite.

Rappel 6/6 zones annotées, mais précision 0,41 — il zoome 38 % de la vidéo. Toucher toutes les zones en arrosant n'est pas de la détection.

Pistes :

  • Ancrer par le retour d'outil. addZoom pourrait renvoyer la position réelle du curseur sur la fenêtre demandée, à côté du focus reçu. Le modèle apprend l'écart au premier appel, sans qu'on lui impose quoi que ce soit. C'est la piste que je préfère : elle informe au lieu de contraindre.
  • Vérifier la lisibilité avant d'accuser la capacité. 356 lignes de {atSec, cx, cy} sont peut-être trop plates pour qu'il y corrèle une fenêtre temporelle. À tester en réduisant d'abord le bruit (piste 1), pas en changeant la forme.
  • Ne pas ajouter de détecteur. Servir au modèle une liste de « moments d'intérêt » le plafonnerait au rappel de l'heuristique — mesuré : le détecteur d'immobilité produit 8 faux positifs sur 16 et rate par construction la zone où l'auteur balaye lentement une image.

3. customScale rend depth inopérant en silence

Mesuré. describe-zooms est passé de 60 % à 98 % après correction de la table depth→échelle. describe-zooms-migrated reste à 33 % : quand un zoom porte un customScale, le depth ne rend plus rien et aucun champ ne le dit au modèle.

Piste. Le snapshot expose déjà depthIsOverridden. Reste à vérifier qu'il atteint le modèle dans tous les chemins, et que setZoom dit clairement que passer depth efface le customScale.

4. Un patron récurrent : l'absence traitée comme un non-événement

Trois occurrences rencontrées en pilotant l'app, sans rapport entre elles :

  • Un asset orphelin vidait tout le preview, sans message (corrigé).
  • Le modèle affirmait qu'aucune donnée curseur n'existait, parce qu'il inspectait un système de fichiers vide (corrigé).
  • Le bouton de transcription ne produit rien quand le binaire Whisper est absent : ni message, ni état d'échec, ni une ligne de log (non corrigé).

Le troisième mérite un correctif, et le patron mérite d'être nommé quelque part : distinguer « je n'ai pas trouvé » de « il n'y a rien » est la même discipline côté UI et côté agent.

5. Le banc : ce qui manque encore

  • Un juge LLM pour l'axe comportemental. Il repose aujourd'hui sur des regex anglaises, dont le module admet lui-même la fragilité — un no a déjà matché dans cannot, accusant de mensonge une réponse honnête. Et « pas de signal » compte comme une réussite, donc une réponse en français passerait au vert sans rien vérifier. Ce qui se calcule doit rester déterministe ; ce qui demande de lire du sens doit passer à un juge, sur les tours persistés, avec verdicts conforme / fautif / indéterminé.
  • Le surajustement au banc. Chaque échec mesuré donne envie d'ajouter une ligne de prompt qui règle ce cas précis. Fait huit fois, le prompt devient la liste des réponses au jeu de tests. Garde-fou proposé : un correctif n'est acceptable que s'il se justifie sans mentionner le scénario qui l'a révélé.
  • Les fixtures ne sont pas versionnées (enregistrements réels, voix transcrite). Reproduire une mesure demande de fournir sa propre prise — voir workbench/fixtures/README.md.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6623524-3082-4994-ac8f-2642283bd8e0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

github-actions Bot and others added 11 commits August 1, 2026 15:42
v7 gave trims a `clipId`, but three READERS still answered "which clip is this?"
from `(assetId, sourceTime)` — or from POSITION IN THE ARRAY. Source time is per
asset: the moment two clips draw on the same media, it names two places at once.
One cause, the two reported symptoms, and two adjacent defects found while
verifying.

--- Playback stops on reaching the second clip

`isWithinClipBounds` gave a clip an EXCLUSIVE closing edge unless it was the LAST
ELEMENT OF THE ARRAY, and the `preferredClipId` guard shared that edge. In its
final ~50 ms — exactly when the rAF loop decides what comes next — a clip disowned
its own last frames and the ambiguous scan handed them to its twin, placing the
playhead near the END of that twin. `reachedClipEnd` then fired on a clip nothing
follows: pause, playhead parked at the end of the timeline.

Because the exception keyed off array position, the bug DEPENDED ON CLIP ORDER,
which is what the reporter's experiments isolated: `A1 -> A2 -> C3` did not bug
(the last clip belongs to another asset, the filter excludes it, the scan returns
null, and the timeline-order fallback did the right thing by accident);
`A1 -> C3 -> A2`, `C3 -> A1 -> A2` and any pair of twins did. All six layouts are
in the test.

An INCLUSIVE bound when the clip is named — identity beats proximity, the
exclusive bound exists only to break ties in the scan and a named clip has no tie
to break — plus a two-pass scan: strict containment first, closing edge only if
nobody claims the instant. Same answer on a single-asset timeline, reached without
consulting the order.

--- "Automatic zooms" only decorates the first clip

Cursor telemetry is recorded against the ORIGINAL file: `timeMs` is the asset's
SOURCE time (the axis `cursor-track.ts` maps through `locateSourcePosition`).
Zooms are authored in RAW timeline ms. The two axes coincide for one layout only:
a single clip, at 0, covering the whole recording. Anywhere else, suggestions
landed where `[0, asset duration]` falls on the ruler — the first clip. Telemetry
was also read only for `videoSources[0]`, so a second recording was never
consulted.

`buildAutoZoomSuggestionsForClips` does the projection, per clip and by a plain
shift (a raw clip is identity between its source and raw-virtual time). A dwell
replayed by two clips therefore yields one zoom on EACH. Each clip sees only the
samples in its own source window: a dwell a cut splits is no longer one dwell,
which is right — the cursor did not sit still across the cut on the timeline being
watched.

--- Two adjacent defects, found while verifying

A cut was never skipped during playback if a twin kept the stretch: "am I inside a
cut?" scanned every segment by asset. `locateKeptSegment` narrows the question to
the segments of the clip being played — a source time none of them covers is
inside THAT clip's cut, however many other clips keep it.

`findNextKeptSegment`'s source-clock fallback compared source positions across a
whole asset. "Later in source time" only means something within one clip: with a
late slice laid down BEFORE a trimmed early slice, entering that cut answered the
first clip — raw start 0 — so playback jumped to the top of the timeline, fell into
the same cut, and looped. Pre-existing, but the fix above makes that path far more
reachable: leaving it would have traded one bug for a worse one.

--- What is NOT touched

`totalVirtualDuration` (`clips.at(-1)`) and `locateVirtualPosition`'s
`index === clips.length - 1` still depend on array order. `resequenceClips`
maintains that order, so neither is reachable today, and `locateVirtualPosition`
has five callers: that is a separate change, not something to smuggle into a
bugfix.

Tests: every fix was verified FAILING without it. The integration test reproduces
the symptom end to end on the pre-fix code (`pause()` at 9.96 s). No existing test
covered the closing edge of a clip with a twin over the same media.
The three new AxcutTrimRange fixtures omitted `reason` / `origin`. Invisible to
`tsc --noEmit`, which excludes `**/*.test.ts(x)` — `tsconfig.test.json` exists
precisely for what the main config leaves out, and it is the gate CI runs. Caught
by review, not by me: I checked the wrong one of the two typecheck jobs.

Two review points taken while here:

- `isWithinClipBounds` defaulted a missing `sourceEndSec` to 0 (as it always had),
  where `resolvePlaybackSegments` reads the same absent field as a zero-width
  window at the in-point. `locateKeptSegment` now feeds this function that very
  output, so the two sit in series and must not read one field two ways. No
  behaviour changes: a clip only awaits probing with `sourceStartSec === 0`, where
  both defaults coincide. A divergence removed, not a bug.
- The per-asset telemetry fetches ran one IPC round trip after another. Nothing in
  the loop depends on visit order (`existingRegions` is read up front), so they now
  run concurrently; `Promise.all` preserves order, so the output is identical.
Le repli logiciel existait déjà sur Linux, mais par accident : `create_backend`
ignorait son paramètre et wgpu rendait lavapipe de lui-même quand c'était le seul
ICD. Ça marche — et c'est précisément le problème, parce que rien ne pouvait
l'exercer, rien ne le signalait et rien ne le garantissait.

- `create_backend` honore enfin son paramètre. `Backend::Cpu` passe par
  `force_fallback_adapter` ; `Backend::Hardware` rejette explicitement un
  adaptateur logiciel, ce qui rend `create` réellement matériel strict (un golden
  mesuré sur llvmpipe passait jusqu'ici pour une mesure GPU). `create_auto` gagne
  le repli explicite Hardware -> Cpu, comme côté Windows.

- `OPENSCREEN_COMPOSITOR_BACKEND=hardware|cpu` force le choix. `VK_DRIVER_FILES`
  ferait la même chose au niveau du loader Vulkan, mais s'applique au processus
  entier : sous Electron il prive aussi Chromium de son GPU, qui rastérise alors
  toute son UI sur CPU et sature la machine. Le chemin CPU n'était donc pas
  testable sans casser la session. Même motif que `OPENSCREEN_EXPORT_ENCODER`.

- L'adaptateur retenu est journalisé. Windows loggue son repli, Linux ne loggait
  rien : un hôte tombé sur lavapipe rendait à quelques fps sans que rien ne
  permette de l'établir à distance.

- `diagnose()` sépare « aucun ICD Vulkan installé » du reste et nomme le paquet à
  installer. C'est la seule panne de cette famille que l'utilisateur peut réparer
  lui-même, et elle s'affichait en « Aperçu indisponible sur cette machine ».

- `classify` s'appuie sur `DeviceType::Cpu` — ce que l'ICD déclare — plutôt que
  sur une sous-chaîne du nom ; le nom reste en filet.

- Le .deb et le pacman déclarent Mesa (`mesa-vulkan-drivers` / `vulkan-swrast`).
  Aucune dépendance n'était déclarée, donc rien ne garantissait qu'un ICD existe.

- Nouveau job CI `Rust test (Linux compositor)`. Le Rust Linux n'était compilé
  nulle part : la CI couvrait macOS et Windows, et les 2154 lignes du moteur wgpu
  ne passaient que par le poste des contributeurs. Le job installe lavapipe, donc
  il exerce pour de vrai le backend CPU sur un runner sans GPU.

Deux corrections que ce job a révélées, et sans lesquelles il ne peut pas exister :

- `export_timing.rs` et `output_geometry_golden.rs` ne compilaient pas sous Linux
  (`probe_frame_count` / `readback_resized` n'existent que côté Windows et macOS).
  Les fichiers de `tests/` étant compilés quelle que soit la plateforme, le crate
  entier était incompilable en `--tests` sur Linux, en silence. Gardés en
  `cfg(not(target_os = "linux"))` plutôt qu'en `cfg(windows)`, pour ne pas les
  retirer du job macOS qui les compile aujourd'hui.

- L'export GIF construisait son device avec `Gpu::create` (matériel strict) alors
  que son propre commentaire dit suivre l'export MP4, qui prend `create_auto`. Un
  hôte sans GPU exportait donc un MP4 mais pas un GIF — sur le seul chemin où le
  backend CPU existe précisément pour que l'export aboutisse. Le rendre strict
  sur Linux sans ce correctif aurait cassé le GIF sur les machines lavapipe qui
  fonctionnent aujourd'hui.
…Linux

`find | head -1` prenait le premier résultat dans l'ordre de parcours du
système de fichiers, qui n'est pas trié. L'image ubuntu-latest embarque
plusieurs LLVM : si l'un d'eux a un paquet -dev préinstallé, on pouvait
sélectionner une version autre que celle qu'apt vient d'installer. Les headers
built-in de clang étant liés à la version de libclang, le symptôme aurait été
`stddef.h file not found` — une erreur qui ne désigne pas sa cause.

`sort -V | tail -1` prend la plus récente, de façon déterministe.

Remonté par CodeRabbit sur #223.
…tions on it

Clicking "Smart cuts with AI" was the natural first move in the editor and the
one that could not work: nothing produced a transcript until the user found the
Media tab or the transcript pane and asked for one. Recognition is local and
free, so the editor now produces transcripts by itself, and every affordance
that needs one says what it is waiting for instead of doing nothing.

- `store/transcriptionStore.ts` owns the queue: one asset at a time (whisper-server
  is a single process and audio extraction holds decoded frames in renderer
  memory), auto-enqueued from `useAutoTranscription()` on any document change.
  The transcript itself still lives on the document — the store only owns the job.
- The auto pass cannot loop: an asset is enqueued only when it has no transcript,
  no job entry (queued / running / failed alike) and no persisted failure, and a
  job is dropped only after the save carrying its transcript resolved. Runs are
  stamped with a run id so a manual regenerate can supersede one mid-flight
  without the outgoing run clearing its successor.
- `transcription/status.ts` holds the vocabulary: `deriveAssetStatus` folds job +
  document into one status (a stored transcript outranks a failed retry — the
  previous transcript is still usable), `resolveTranscriptGate` folds a set of
  those into ready / pending / blocked, resolved over the assets the TIMELINE
  plays rather than over `primaryAssetId` (which in a recording project is the
  screen capture, routinely the silent one).
- Silent media are remembered: a container with no audio track fails the same way
  every time, so the verdict lands on `asset.transcriptionFailure` (additive
  schema field, no version bump) and the auto pass stops re-extracting its audio
  on every project open. Transient failures stay in memory and retry next load.
- No local STT engine (browser preview, e2e shim) means no background pass; a
  manual request still runs.
- Removes `projectStore.setTranscript` (it duplicated `withTranscript`) and the
  `assetStatuses` / `onRegenerateAsset` / `onTranscribe` / `isTranscribing` prop
  chains — the panes read the store.

25 tests cover the pure logic, the queue (loop guard, supersede, project switch,
no engine, silent-verdict persistence) and the rendered gating.
…download

The 253 MB model download happens inside the `stt:transcribe` IPC — i.e. inside a
run the store has already marked `running` — so it correctly reads as one longer
"Transcribing…" phase, with no separate step and nothing falsely clickable. The
edges of that first run did not hold up, though:

- `SttManager.init` cached a REJECTED `prepare()`, so one dropped connection
  during the download failed every later transcription in the session: the
  remaining assets in the queue flipped red in the same frame, and the retry the
  editor offers was a dead control until the app was restarted — reconnecting
  changed nothing. The slot is cleared on failure now.
- A transient failure now stops the queue: the still-queued assets inherit the
  verdict instead of each spending a full retry budget and stacking an identical
  toast. It is the engine that failed, not their media.
- The transcript pane's read-only state is scoped PER ASSET again. Widening it to
  the timeline-wide gate made every other clip's word stream swallow Backspace
  and hover-bin clicks for the whole background pass, with nothing on screen to
  say why — the exact "looks live, ignores you" failure mode. A block being
  rewritten now shows a spinner + "Transcribing…" and dims its stream, and the
  per-clip empty line no longer tells the user to regenerate an asset that is
  mid-run.
- `mixToMono` hoists its channel arrays out of the sample loop. One WebIDL call
  per sample per channel (~57 M for a ten-minute stereo recording) froze the
  window — spinners included — for seconds, which was survivable while the pass
  was user-triggered and is not now that it is automatic.

Found by an adversarial review of the first-run sequence; 4 new tests cover the
init retry, the queue stop and the per-asset read-only scoping.
`tsc -p tsconfig.test.json` (its own CI job) rejected the two held-open-run
tests: assigning the resolver inside a Promise executor leaves the outer
`let releaseFirst: (() => void) | null` narrowed to `null`, so `releaseFirst?.()`
is a call on `never`. A small `deferred()` helper hands back a stable 0-arg
release instead.

Also fixes the instruction that let this reach CI: AGENTS.md said `npx tsc
--noEmit` was what CI runs, and that config does not see test files at all.
…warning

`.claude/` is gitignored (while `launch.json` is tracked), so its local
desktop-dev tweak slipped in through `git add -u` — reverted here, it stays a
local change. Also puts a comment in `deferred()`'s placeholder so it stops
tripping noEmptyBlockStatements.
Document de travail: chaque piste porte la mesure qui la justifie et la
contre-mesure qui la départagera. Rien n'est appliqué ici.
…pels par lot

Le track n'était pas la cause des échecs: les deux tours réussis prenaient 117 s
et 112 s pour un couperet à 120 s. Ce que révèle la mesure, c'est que le tour
émet 19 appels d'outils en série — six addTrim et neuf addZoom un par un.
@EtienneLescot
EtienneLescot force-pushed the claude/agent-improvement-leads branch from 0d2dbcb to 64b2f74 Compare August 1, 2026 13:48
@EtienneLescot
EtienneLescot changed the base branch from release/v1.8.0 to main August 1, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant