diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 1cba39c7c76..4e8399fdb6b 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -912,6 +912,8 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` +Under the table, **Change history** lists the newest changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, in whole batches and never more than 50, so an Apply all is listed with every role it wrote or not at all, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory, which retains at most 256 KiB and 1000 rows by dropping its oldest whole batches. Compaction targets 128 KiB and preserves a Restore together with its complete source batch when they fit both limits, making room by dropping unrelated older batches; history can still expire as later writes arrive. The writer captures the bounded history before appending so a Restore does not evict its own source at the retention boundary; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. + ## Kilo Kilo CLI, VS Code, and JetBrains share one global config. This integration writes diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3edd48748e0..e9484f5a019 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1905,6 +1905,12 @@ export const de: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} läuft jetzt mit {model}. omo.jsonc blieb unverändert, weil es kein gültiges JSON in der Form ist, die LazyCodex liest.", "integrations.lazycodexRoles.omoFailed": "{role} läuft jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", + "integrations.lazycodexRoles.history.title": "Änderungsverlauf", + "integrations.lazycodexRoles.history.hint": "Hier stehen die letzten 50 Speichervorgänge und automatischen Zuweisungen dieses Abschnitts, jeweils als ganze Aktion. Wiederherstellen schreibt das frühere Modell und den früheren Aufwand wie ein normales Speichern zurück und wird ebenfalls aufgeführt. Eine Rolle ohne frühere Modellbindung behält ihr aktuelles Modell; nur der Aufwand wird zurückgesetzt.", + "integrations.lazycodexRoles.history.unavailable": "Der Änderungsverlauf konnte nicht gelesen werden, daher ist Wiederherstellen nicht verfügbar. Rollen lassen sich weiterhin bearbeiten.", + "integrations.lazycodexRoles.history.restore": "Wiederherstellen", + "integrations.lazycodexRoles.history.restored": "{count} Rolle(n) auf den Stand vor {time} zurückgesetzt.", + "integrations.lazycodexRoles.history.partial": "Wiederherstellen bei {role} abgebrochen: {count} von {total} Rolle(n) wurden zurückgesetzt, die übrigen behalten ihre aktuellen Werte. Behebe den Fehler oben und stelle erneut wieder her.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Setze [codex].agents in omo.jsonc selbst.", "integrations.lazycodexRoles.auto.button": "Automatisch zuweisen", "integrations.lazycodexRoles.auto.running": "Rollen werden eingestuft…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 538167a0c02..5d0d22253f8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2536,6 +2536,12 @@ export const en = { "integrations.lazycodexRoles.omoInvalid": "{role} now runs on {model}. omo.jsonc was left alone because it is not valid JSON in the shape LazyCodex reads.", "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model}, but omo.jsonc could not be written.", "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", + "integrations.lazycodexRoles.history.title": "Change history", + "integrations.lazycodexRoles.history.hint": "The newest 50 saves and auto-assigns in this section are listed here, kept in whole batches. Restore writes the earlier model and effort back as an ordinary save, so it is listed too. A role that had no model pin keeps its current one; only its effort goes back.", + "integrations.lazycodexRoles.history.unavailable": "The change history could not be read, so Restore is unavailable. Role editing still works.", + "integrations.lazycodexRoles.history.restore": "Restore", + "integrations.lazycodexRoles.history.restored": "Restored {count} role(s) to how they were before {time}.", + "integrations.lazycodexRoles.history.partial": "Restore stopped at {role}: {count} of {total} role(s) went back, and the rest keep their current values. Fix the error above and restore again.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc has comments, so saving here updates only the role files. Set [codex].agents in omo.jsonc yourself.", "integrations.lazycodexRoles.auto.button": "Auto-assign", "integrations.lazycodexRoles.auto.running": "Sizing roles…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 772fdf9cca0..847a71db52a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2463,6 +2463,12 @@ export const fr: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} utilise désormais {model}. omo.jsonc n'a pas été modifié, car ce n'est pas un JSON valide de la forme que LazyCodex lit.", "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model}, mais omo.jsonc n'a pas pu être écrit.", "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", + "integrations.lazycodexRoles.history.title": "Historique des modifications", + "integrations.lazycodexRoles.history.hint": "Les 50 derniers enregistrements et attributions automatiques de cette section figurent ici, regroupés par action entière. Restaurer réécrit le modèle et l'effort précédents comme un enregistrement ordinaire, qui figure donc aussi ici. Un rôle sans modèle épinglé auparavant garde son modèle actuel ; seul son effort est rétabli.", + "integrations.lazycodexRoles.history.unavailable": "L'historique des modifications n'a pas pu être lu, donc Restaurer n'est pas disponible. La modification des rôles fonctionne toujours.", + "integrations.lazycodexRoles.history.restore": "Restaurer", + "integrations.lazycodexRoles.history.restored": "{count} rôle(s) rétabli(s) à leur état d'avant {time}.", + "integrations.lazycodexRoles.history.partial": "La restauration s'est arrêtée à {role} : {count} rôle(s) sur {total} ont été rétablis, les autres gardent leurs valeurs actuelles. Corrigez l'erreur ci-dessus et restaurez à nouveau.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : enregistrer ici ne met à jour que les fichiers de rôle. Définissez [codex].agents dans omo.jsonc vous-même.", "integrations.lazycodexRoles.auto.button": "Attribution auto", "integrations.lazycodexRoles.auto.running": "Évaluation des rôles…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 067dc34a507..65ff55a8905 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2358,6 +2358,12 @@ export const ja: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} は {model} で動くようになりました。omo.jsonc は LazyCodex が読む形の有効な JSON ではないため変更していません。", "integrations.lazycodexRoles.omoFailed": "{role} は {model} で動くようになりましたが、omo.jsonc は書き込めませんでした。", "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", + "integrations.lazycodexRoles.history.title": "変更履歴", + "integrations.lazycodexRoles.history.hint": "このセクションでの保存と自動割り当てのうち、新しい 50 件が操作単位でここに記録されます。元に戻すと以前のモデルと推論強度が通常の保存と同じ方法で書き戻され、その操作も記録されます。以前モデルが固定されていなかったロールは現在のモデルを保ち、推論強度だけを戻します。", + "integrations.lazycodexRoles.history.unavailable": "変更履歴を読み込めなかったため、元に戻す機能は使えません。ロールの編集はそのまま使えます。", + "integrations.lazycodexRoles.history.restore": "元に戻す", + "integrations.lazycodexRoles.history.restored": "{count} 件のロールを {time} より前の状態に戻しました。", + "integrations.lazycodexRoles.history.partial": "{role} で元に戻す処理が止まりました。{total} 件中 {count} 件のロールが戻り、残りは現在の値のままです。上のエラーを解消してからもう一度元に戻してください。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが更新されます。omo.jsonc の [codex].agents はご自身で設定してください。", "integrations.lazycodexRoles.auto.button": "自動割り当て", "integrations.lazycodexRoles.auto.running": "ロールを評価中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8237f6c18c6..c1d5bdc467e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1949,6 +1949,12 @@ export const ko: Record = { "integrations.lazycodexRoles.omoInvalid": "{role}은(는) 이제 {model}로 실행됩니다. omo.jsonc가 LazyCodex가 읽는 형태의 올바른 JSON이 아니어서 그 파일은 바꾸지 않았습니다.", "integrations.lazycodexRoles.omoFailed": "{role}은(는) 이제 {model}로 실행되지만, omo.jsonc는 쓰지 못했습니다.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", + "integrations.lazycodexRoles.history.title": "변경 기록", + "integrations.lazycodexRoles.history.hint": "이 섹션에서 저장하거나 자동 배정한 변경 중 최근 50개가 작업 단위로 여기에 남습니다. 되돌리기는 이전 모델과 추론 강도를 일반 저장과 같은 방식으로 다시 쓰고, 그 자체도 기록됩니다. 원래 모델이 지정되지 않았던 역할은 현재 모델을 유지하고 추론 강도만 되돌립니다.", + "integrations.lazycodexRoles.history.unavailable": "변경 기록을 읽지 못해 되돌리기를 쓸 수 없습니다. 역할 편집은 그대로 할 수 있습니다.", + "integrations.lazycodexRoles.history.restore": "되돌리기", + "integrations.lazycodexRoles.history.restored": "{count}개 역할을 {time} 이전 상태로 되돌렸습니다.", + "integrations.lazycodexRoles.history.partial": "{role}에서 되돌리기가 멈췄습니다. {total}개 중 {count}개 역할만 되돌아갔고 나머지는 현재 값을 유지합니다. 위의 오류를 해결한 뒤 다시 되돌리세요.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 [codex].agents는 직접 맞춰 주세요.", "integrations.lazycodexRoles.auto.button": "자동 배정", "integrations.lazycodexRoles.auto.running": "역할 평가 중…", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 6a699319003..a497da49856 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2507,6 +2507,12 @@ export const pt: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} agora usa {model}. O omo.jsonc ficou como estava porque não é um JSON válido no formato que o LazyCodex lê.", "integrations.lazycodexRoles.omoFailed": "{role} agora usa {model}, mas não foi possível gravar o omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Tentar omo.jsonc novamente", + "integrations.lazycodexRoles.history.title": "Histórico de alterações", + "integrations.lazycodexRoles.history.hint": "As 50 gravações e atribuições automáticas mais recentes desta seção aparecem aqui, agrupadas por ação inteira. Restaurar grava de volta o modelo e o esforço anteriores como uma gravação comum, que também aparece aqui. Um papel que não tinha modelo fixado mantém o atual; só o esforço volta.", + "integrations.lazycodexRoles.history.unavailable": "Não foi possível ler o histórico de alterações, por isso Restaurar não está disponível. A edição de papéis continua funcionando.", + "integrations.lazycodexRoles.history.restore": "Restaurar", + "integrations.lazycodexRoles.history.restored": "{count} papel(is) restaurado(s) ao estado anterior a {time}.", + "integrations.lazycodexRoles.history.partial": "A restauração parou em {role}: {count} de {total} papel(is) voltaram, e os demais mantêm os valores atuais. Corrija o erro acima e restaure novamente.", "integrations.lazycodexRoles.omoCommentsState": "O omo.jsonc tem comentários, então salvar aqui atualiza só os arquivos de papel. Defina [codex].agents no omo.jsonc você mesmo.", "integrations.lazycodexRoles.auto.button": "Atribuir automaticamente", "integrations.lazycodexRoles.auto.running": "Dimensionando funções…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 028a9f775ed..49ad2ab7931 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2449,6 +2449,12 @@ export const ru: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} теперь работает на {model}. omo.jsonc не изменён: это не корректный JSON той структуры, которую читает LazyCodex.", "integrations.lazycodexRoles.omoFailed": "{role} теперь работает на {model}, но записать omo.jsonc не удалось.", "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", + "integrations.lazycodexRoles.history.title": "История изменений", + "integrations.lazycodexRoles.history.hint": "Здесь перечислены последние 50 сохранений и автоназначений в этом разделе, целыми действиями. Восстановление записывает прежние модель и усилие как обычное сохранение и тоже попадает сюда. Роль, у которой раньше не было закреплённой модели, сохраняет текущую; возвращается только усилие.", + "integrations.lazycodexRoles.history.unavailable": "Не удалось прочитать историю изменений, поэтому восстановление недоступно. Редактирование ролей по-прежнему работает.", + "integrations.lazycodexRoles.history.restore": "Восстановить", + "integrations.lazycodexRoles.history.restored": "Восстановлено ролей: {count}, состояние до {time}.", + "integrations.lazycodexRoles.history.partial": "Восстановление остановилось на {role}: возвращено ролей {count} из {total}, остальные сохраняют текущие значения. Исправьте ошибку выше и восстановите снова.", "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь обновляет только файлы ролей. Задайте [codex].agents в omo.jsonc сами.", "integrations.lazycodexRoles.auto.button": "Автоназначение", "integrations.lazycodexRoles.auto.running": "Оценка ролей…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9965c216db9..c05c5b8eb1c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2475,6 +2475,12 @@ export const tr: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} artık {model} ile çalışıyor. omo.jsonc, LazyCodex'in okuduğu biçimde geçerli bir JSON olmadığı için değiştirilmedi.", "integrations.lazycodexRoles.omoFailed": "{role} artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", + "integrations.lazycodexRoles.history.title": "Değişiklik geçmişi", + "integrations.lazycodexRoles.history.hint": "Bu bölümdeki en yeni 50 kaydetme ve otomatik atama, bütün işlemler halinde burada listelenir. Geri yükleme önceki modeli ve çabayı sıradan bir kaydetme gibi geri yazar ve o da listelenir. Önceden sabitlenmiş modeli olmayan bir rol mevcut modelini korur; yalnızca çabası geri döner.", + "integrations.lazycodexRoles.history.unavailable": "Değişiklik geçmişi okunamadı, bu yüzden Geri yükleme kullanılamıyor. Rol düzenleme çalışmaya devam ediyor.", + "integrations.lazycodexRoles.history.restore": "Geri yükle", + "integrations.lazycodexRoles.history.restored": "{count} rol {time} öncesindeki durumuna geri yüklendi.", + "integrations.lazycodexRoles.history.partial": "Geri yükleme {role} rolünde durdu: {total} rolden {count} tanesi geri döndü, diğerleri mevcut değerlerini koruyor. Yukarıdaki hatayı düzeltip yeniden geri yükleyin.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorumlar içeriyor, bu yüzden burada kaydetmek yalnızca rol dosyalarını günceller. omo.jsonc içindeki [codex].agents değerini kendiniz ayarlayın.", "integrations.lazycodexRoles.auto.button": "Otomatik ata", "integrations.lazycodexRoles.auto.running": "Roller değerlendiriliyor…", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c416a426462..80ffac1e14e 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2484,6 +2484,12 @@ export const vi: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} giờ chạy bằng {model}. omo.jsonc được giữ nguyên vì nó không phải JSON hợp lệ theo dạng LazyCodex đọc.", "integrations.lazycodexRoles.omoFailed": "{role} giờ chạy bằng {model}, nhưng không ghi được omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", + "integrations.lazycodexRoles.history.title": "Lịch sử thay đổi", + "integrations.lazycodexRoles.history.hint": "50 lần lưu và tự động gán gần nhất trong phần này được liệt kê ở đây, theo từng thao tác trọn vẹn. Khôi phục ghi lại mô hình và mức suy luận trước đó như một lần lưu thông thường, nên thao tác đó cũng được liệt kê. Vai trò trước đây không ghim mô hình sẽ giữ mô hình hiện tại; chỉ mức suy luận được khôi phục.", + "integrations.lazycodexRoles.history.unavailable": "Không đọc được lịch sử thay đổi nên không thể khôi phục. Bạn vẫn chỉnh sửa vai trò như bình thường.", + "integrations.lazycodexRoles.history.restore": "Khôi phục", + "integrations.lazycodexRoles.history.restored": "Đã khôi phục {count} vai trò về trạng thái trước {time}.", + "integrations.lazycodexRoles.history.partial": "Khôi phục dừng ở {role}: {count}/{total} vai trò đã được khôi phục, các vai trò còn lại giữ giá trị hiện tại. Hãy sửa lỗi ở trên rồi khôi phục lại.", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích, nên lưu ở đây chỉ cập nhật các tệp vai trò. Hãy tự đặt [codex].agents trong omo.jsonc.", "integrations.lazycodexRoles.auto.button": "Tự động gán", "integrations.lazycodexRoles.auto.running": "Đang đánh giá vai trò…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a2d1b5a9a7c..102b18f5749 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3396,6 +3396,12 @@ export const zhTW: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} 現在使用 {model} 執行。omo.jsonc 不是 LazyCodex 可讀取格式的有效 JSON,因此未修改。", "integrations.lazycodexRoles.omoFailed": "{role} 現在使用 {model} 執行,但無法寫入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", + "integrations.lazycodexRoles.history.title": "變更紀錄", + "integrations.lazycodexRoles.history.hint": "本區段中最近 50 次儲存與自動分配會以整次操作記錄在這裡。還原會像一般儲存一樣寫回先前的模型與推理強度,這次操作本身也會被記錄。原先沒有固定模型的角色保留目前模型,只還原推理強度。", + "integrations.lazycodexRoles.history.unavailable": "無法讀取變更記錄,因此無法還原。角色編輯仍可正常使用。", + "integrations.lazycodexRoles.history.restore": "還原", + "integrations.lazycodexRoles.history.restored": "已將 {count} 個角色還原到 {time} 之前的狀態。", + "integrations.lazycodexRoles.history.partial": "在 {role} 處停止還原:{total} 個角色中已還原 {count} 個,其餘保留目前的值。請先修正上方的錯誤,再重試還原。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有註解,因此在這裡儲存只會更新角色檔案。請自行設定 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自動指派", "integrations.lazycodexRoles.auto.running": "正在評估角色…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 23075e443f1..38bebc8b9b6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1922,6 +1922,12 @@ export const zh: Record = { "integrations.lazycodexRoles.omoInvalid": "{role} 现在使用 {model} 运行。omo.jsonc 不是 LazyCodex 可读取格式的有效 JSON,因此未修改。", "integrations.lazycodexRoles.omoFailed": "{role} 现在使用 {model} 运行,但无法写入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", + "integrations.lazycodexRoles.history.title": "变更记录", + "integrations.lazycodexRoles.history.hint": "本节中最近 50 次保存和自动分配会以整次操作记录在这里。恢复会像普通保存一样写回之前的模型和推理强度,这次操作本身也会被记录。原先没有固定模型的角色保留当前模型,只恢复推理强度。", + "integrations.lazycodexRoles.history.unavailable": "无法读取变更记录,因此无法恢复。角色编辑仍可正常使用。", + "integrations.lazycodexRoles.history.restore": "恢复", + "integrations.lazycodexRoles.history.restored": "已将 {count} 个角色恢复到 {time} 之前的状态。", + "integrations.lazycodexRoles.history.partial": "在 {role} 处停止恢复:{total} 个角色中已恢复 {count} 个,其余保留当前的值。请先修复上方的错误,再重试恢复。", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 中有注释,因此在这里保存只会更新角色文件。请自行设置 omo.jsonc 中的 [codex].agents。", "integrations.lazycodexRoles.auto.button": "自动分配", "integrations.lazycodexRoles.auto.running": "正在评估角色…", diff --git a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx index 4b80c36a581..6ac6ebfe1dd 100644 --- a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { readJsonOrThrow } from "../../fetch-json"; import { useT } from "../../i18n/shared"; import { Notice } from "../../ui"; +import type { SavedRole } from "./LazyCodexRoleHistory"; import { EFFORT_LABEL, TIER_LABEL, type SizingEffortIntent, type SizingTier } from "./sizing-labels"; export interface RoleProposal { @@ -36,7 +37,7 @@ export default function LazyCodexRoleAutoAssign({ }: { apiBase: string; busy: boolean; - apply: (role: string, model: string, effort: string | undefined) => Promise; + apply: (role: string, model: string, effort: string | undefined, batch?: string) => Promise; }) { const t = useT(); const [running, setRunning] = useState(false); @@ -70,20 +71,31 @@ export default function LazyCodexRoleAutoAssign({ const applicable = (result?.proposals ?? []).filter(p => p.status === "proposed" && p.proposedModel && !applied[p.role] && !alreadySet(p)); - const applyOne = async (proposal: RoleProposal): Promise => { - const ok = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined); - if (ok) setApplied(current => ({ ...current, [proposal.role]: true })); - return ok; + const applyOne = async (proposal: RoleProposal, batch?: string): Promise => { + const saved = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? undefined, batch); + if (saved) setApplied(current => ({ ...current, [proposal.role]: true })); + return saved; }; const applyAll = async () => { setApplying(true); - let count = 0; - const total = applicable.length; - // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror - for (const proposal of applicable) if (await applyOne(proposal)) count += 1; - setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); - setApplying(false); + try { + let count = 0; + const total = applicable.length; + // One batch, so the role history restores the whole Apply all at once: the first recorded write + // starts it and the server names it, and every later write joins it. + let batch: string | undefined; + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const proposal of applicable) { + const saved = await applyOne(proposal, batch); + if (!saved) continue; + batch ??= saved.batch; + count += 1; + } + setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); + } finally { + setApplying(false); + } }; const locked = running || applying || busy; diff --git a/gui/src/pages/integrations/LazyCodexRoleHistory.tsx b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx new file mode 100644 index 00000000000..ac1e18a95a2 --- /dev/null +++ b/gui/src/pages/integrations/LazyCodexRoleHistory.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { useI18n } from "../../i18n/shared"; +import { Notice } from "../../ui"; + +export interface RoleSetting { + model: string | null; + effort: string | null; +} + +export interface RoleChange { + at: string; + batch: string; + role: string; + before: RoleSetting; + after: RoleSetting; +} + +/** A saved role write: the batch the server recorded it in, absent when it recorded nothing. */ +export interface SavedRole { + batch?: string; +} + +/** + * `batch` joins the batch the first write of this Restore started (the server hands out batch ids); + * `restores` names the batch being put back, and the server checks the write against its own history. + */ +type Restore = (role: string, model: string | undefined, effort: string | null, batch: string | undefined, restores: string) => Promise; + +/** Newest first, as the server lists them; one entry per batch, so Apply all reads as one action. */ +function groupByBatch(history: RoleChange[]): RoleChange[][] { + const groups = new Map(); + for (const change of history) groups.set(change.batch, [...(groups.get(change.batch) ?? []), change]); + return [...groups.values()]; +} + +/** A role changed twice in one batch goes back to what it held before the first change. */ +function earliestBefore(group: RoleChange[]): Map { + const before = new Map(); + for (const change of group) before.set(change.role, change.before); + return before; +} + +export default function LazyCodexRoleHistory({ history, unavailable, busy, restore }: { history: RoleChange[]; unavailable: boolean; busy: boolean; restore: Restore }) { + const { locale, t } = useI18n(); + const [restoring, setRestoring] = useState(null); + const [summary, setSummary] = useState<{ tone: "ok" | "warn"; text: string } | null>(null); + if (unavailable) return {t("integrations.lazycodexRoles.history.unavailable")}; + if (history.length === 0) return null; + + const describe = (setting: RoleSetting) => { + const model = setting.model ?? t("integrations.lazycodexRoles.none"); + return setting.effort ? `${model} · ${setting.effort}` : model; + }; + + const restoreGroup = async (group: RoleChange[]) => { + setRestoring(group[0]!.batch); + setSummary(null); + try { + let batch: string | undefined; + const roles = [...earliestBefore(group)]; + let count = 0; + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const [role, setting] of roles) { + // A role that had no model pin keeps its current one: a save can set a pin, not remove it. + const saved = await restore(role, setting.model ?? undefined, setting.effort, batch, group[0]!.batch); + if (!saved) break; + batch ??= saved.batch; + count += 1; + } + const time = new Date(group[0]!.at).toLocaleString(locale); + setSummary(count === roles.length + ? { tone: "ok", text: t("integrations.lazycodexRoles.history.restored", { count: String(count), time }) } + : { tone: "warn", text: t("integrations.lazycodexRoles.history.partial", { count: String(count), total: String(roles.length), role: roles[count]![0] }) }); + } finally { + setRestoring(null); + } + }; + + return ( +
+
{t("integrations.lazycodexRoles.history.title")}
+

{t("integrations.lazycodexRoles.history.hint")}

+ {summary && {summary.text}} +
    + {groupByBatch(history).map(group => ( +
  • + {new Date(group[0]!.at).toLocaleString(locale)} +
      + {group.map(change => ( +
    • + {change.role}: {describe(change.before)} → {describe(change.after)} +
    • + ))} +
    + +
  • + ))} +
+
+ ); +} diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index 185010d4ec8..5949a7a1450 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -5,6 +5,7 @@ import { readJsonOrThrow } from "../../fetch-json"; import { useT, type TKey } from "../../i18n/shared"; import { Notice, Select, type SelectOption } from "../../ui"; import LazyCodexRoleAutoAssign from "./LazyCodexRoleAutoAssign"; +import LazyCodexRoleHistory, { type RoleChange, type SavedRole } from "./LazyCodexRoleHistory"; interface RoleRow { role: string; @@ -19,6 +20,8 @@ type OmoWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | interface RoleModels { detected: boolean; roles: RoleRow[]; + history: RoleChange[]; + historyUnavailable: boolean; omoJsonc: { state: OmoFileState } | null; available: string[]; efforts: string[]; @@ -40,11 +43,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri const [result, setResult] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); const load = useCallback(async (signal: AbortSignal): Promise => { - const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; omoJsonc?: { state: OmoFileState } | null }>( + const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; history?: RoleChange[]; historyUnavailable?: boolean; omoJsonc?: { state: OmoFileState } | null }>( await fetch(`${apiBase}/api/codex-agent-roles`, { signal }), t("integrations.lazycodexRoles.loadFailed"), ); - if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], omoJsonc: null, available: [], efforts: [] }; + if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], history: [], historyUnavailable: false, omoJsonc: null, available: [], efforts: [] }; // The effort list is the one the Subagents page offers for its delegation model. It only adds // choices, so a failed lookup leaves each row with its pinned effort instead of failing the table. const [models, delegation] = await Promise.all([ @@ -57,6 +60,8 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri return { detected: true, roles: roles.roles ?? [], + history: roles.history ?? [], + historyUnavailable: roles.historyUnavailable === true, omoJsonc: roles.omoJsonc ?? null, available: models?.available ?? [], efforts: delegation?.efforts ?? [], @@ -73,22 +78,35 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri useLayoutEffect(() => { latestData.current = data; }, [data]); // An undefined model saves the effort alone and leaves the role's model pin, or its inheritance, - // as it is. Auto-assign and model picks always pass a model. - const save = async (role: string, model: string | undefined, effort: string | null | undefined = undefined, displayedEffort?: string): Promise => { - if (pending !== null) return false; + // as it is. Auto-assign and model picks always pass a model. A save resolves to the batch the + // server recorded it in (absent when nothing was written or recorded), or null when it failed. + const save = async ( + role: string, + model: string | undefined, + effort: string | null | undefined = undefined, + displayedEffort?: string, + batch?: string, + restores?: string, + ): Promise => { + if (pending !== null) return null; // An omitted effort still carries a pending omo.jsonc retry's effort, so a later save from any // path (the row or auto-assign) cannot drop it. An explicit effort replaces the retry. const sent = effort === undefined ? mirrorRetries[role]?.sent : effort; - if (model === undefined && sent === undefined) return false; + if (model === undefined && sent === undefined) return null; setPending(role); setResult(null); try { const response = await fetch(`${apiBase}/api/codex-agent-roles/${encodeURIComponent(role)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...(model !== undefined ? { model } : {}), ...(sent !== undefined ? { effort: sent } : {}) }), + body: JSON.stringify({ + ...(model !== undefined ? { model } : {}), + ...(sent !== undefined ? { effort: sent } : {}), + ...(batch !== undefined ? { batch } : {}), + ...(restores !== undefined ? { restores } : {}), + }), }); - const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( + const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus }; batch?: string }>( response, t("integrations.lazycodexRoles.saveFailed", { role }), ); @@ -128,10 +146,10 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri setEffortDrafts(current => keepOnFailure(current, shownEffort)); setMirrorRetries(current => keepOnFailure(current, { sent })); await resource.refresh(); - return true; + return { batch: payload?.batch }; } catch { setResult({ tone: "err", text: t("integrations.lazycodexRoles.saveFailed", { role }) }); - return false; + return null; } finally { setPending(null); } @@ -167,7 +185,11 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri

{t("integrations.lazycodexRoles.empty")}

) : ( <> - + save(role, model, effort, undefined, batch)} + />
@@ -235,6 +257,12 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri
+ save(role, model, effort, undefined, batch, restores)} + /> )} diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 67ddbb12425..d2b83ac6568 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -1819,7 +1819,7 @@ test("the LazyCodex role section sits on the omo tab and only when LazyCodex is mountCount += 1; apiBase = `http://ocx-test-${mountCount}.invalid`; lazycodexRoles = { lazycodex: { detected: roles.detected }, omoJsonc: roles.omoJsonc, roles: roles.roles }; - setClientResourceData(`lazycodex-role-models:${apiBase}`, roles); + setClientResourceData(`lazycodex-role-models:${apiBase}`, { history: [], ...roles }); await act(async () => { root?.unmount(); root = createRoot(container); diff --git a/gui/tests/lazycodex-role-auto-assign.test.tsx b/gui/tests/lazycodex-role-auto-assign.test.tsx index 23f13af3b2c..a39cacc63f6 100644 --- a/gui/tests/lazycodex-role-auto-assign.test.tsx +++ b/gui/tests/lazycodex-role-auto-assign.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; -const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "crypto", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -62,7 +62,8 @@ beforeEach(() => { const body = JSON.parse(String(init?.body)); const role = url.slice(url.lastIndexOf("/") + 1); roleRows = roleRows.map(row => row.role === role ? { ...row, model: body.model ?? row.model, effort: body.effort === undefined ? row.effort : body.effort } : row); - return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: mirrorStatus } }); + // Like the server, a write without a batch starts one and the response names it. + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: mirrorStatus }, batch: body.batch ?? `server-${requests.length}` }); } if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "a/small", "a/mid"] }); if (url.endsWith("/api/injection-model")) return json({ efforts: ["low", "medium", "high"] }); @@ -146,7 +147,7 @@ test("apply all writes each remaining proposal once and skips unsized roles", as await click(button("Apply all")); expect(requests.slice(1)).toEqual([ { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low" } }, - { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid" } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid", batch: "server-2" } }, ]); expect(container.textContent).toContain("Applied 2 of 2 proposals."); expect(button("Apply all").disabled).toBe(true); @@ -206,7 +207,7 @@ test("Apply all merges both confirmed rows when every reload fails", async () => await click(button("Auto-assign")); reloadFails = true; await click(button("Apply all")); - expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low" }, { model: "a/mid" }]); + expect(requests.slice(1).map(r => r.body)).toEqual([{ model: "a/small", effort: "low" }, { model: "a/mid", batch: "server-2" }]); expect(roleRow("explorer").querySelectorAll("td")[1]!.textContent).toBe("a/small · low"); expect(roleRow("worker").querySelectorAll("td")[1]!.textContent).toBe("a/mid · high"); await expectEffortOptions("explorer", ["Inherit effort", "low", "medium", "high"]); @@ -255,3 +256,18 @@ test("Apply after a failed effort-only mirror on a model-inheriting role carries ]); expect(roleRow("worker").querySelectorAll("td")[1]!.textContent).toBe("a/mid · low"); }); + +// A dashboard opened over plain HTTP on the LAN is not a secure context: randomUUID is missing or throws. +for (const availability of ["absent", "throws"] as const) { + test(`Apply all writes one batch and unlocks the panel when randomUUID is ${availability}`, async () => { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: availability === "absent" ? {} + : { randomUUID: () => { throw new Error("insecure context"); } } }); + await mount(); + await click(button("Auto-assign")); + await click(button("Apply all")); + const batches = requests.slice(1).map(r => (r.body as { batch?: string }).batch); + expect(batches).toEqual([undefined, "server-2"]); + expect(container.textContent).toContain("Applied 2 of 2 proposals."); + expect(button("Auto-assign").disabled).toBe(false); + }); +} diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx index a4f454abe1e..0b5c8927ef2 100644 --- a/gui/tests/lazycodex-role-models.test.tsx +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -4,7 +4,7 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { validateAgentRoleEffort } from "../../src/codex/agent-role-models"; -const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "crypto", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -82,7 +82,8 @@ beforeEach(() => { omoJsoncModel: omoWriteStatus === "written" && model !== undefined ? model : row.omoJsoncModel ?? null, } : row), }; - return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus } }); + // Like the server, a write without a batch starts one and the response names it. + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus }, batch: body.batch ?? `server-${puts.length}` }); } if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "xai/grok-4.5"] }); if (url.endsWith("/api/injection-model")) { @@ -535,3 +536,74 @@ test("picking a model after a failed effort-only mirror sends both, and only the expect(puts.map(entry => entry.body)).toEqual([{ effort: "low" }, { model: "xai/grok-4.5", effort: "low" }]); expect(current("librarian")).toBe("xai/grok-4.5 · low"); }); + +test("lists the role change history as one entry per batch and restores a batch's earlier values together", async () => { + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + const entries = container.querySelectorAll(".lazycodex-role-history > ul > li"); + expect(entries.length).toBe(1); + expect(entries[0]!.textContent).toContain("explorer: gpt-5.5 → xai/grok-4.5 · low"); + const restore = entries[0]!.querySelector("button") as HTMLButtonElement; + await act(async () => { restore.click(); }); + await settle(); + expect(puts.map(put => ({ role: put.url.slice(put.url.lastIndexOf("/") + 1), body: put.body }))).toEqual([ + { role: "librarian", body: { effort: "medium", restores: "apply-all" } }, + { role: "explorer", body: { model: "gpt-5.5", effort: null, batch: "server-1", restores: "apply-all" } }, + ]); + expect(container.textContent).toContain("Restored 2 role(s)"); +}); + +test("keeps the role table editable and says why Restore is gone when the history cannot be read", async () => { + rolesBody = { ...(rolesBody as Record), history: [], historyUnavailable: true }; + await mount(); + expect(container.textContent).toContain("The change history could not be read"); + expect(container.querySelector(".lazycodex-role-history")).toBeNull(); + expect(saveButton("explorer")).not.toBeNull(); +}); + +test("stops a Restore at the first failed role and warns that the batch is only partly back", async () => { + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + putFailure = { status: 500, body: { error: "write failed" } }; + const restore = container.querySelector(".lazycodex-role-history > ul > li button") as HTMLButtonElement; + await act(async () => { restore.click(); }); + await settle(); + expect(puts).toHaveLength(1); + expect(container.textContent).toContain("Restore stopped at librarian: 0 of 2 role(s) went back"); + expect(container.textContent).not.toContain("Restored 0 role(s)"); +}); + +for (const availability of ["absent", "throws"] as const) { + test(`Restore writes the batch back and frees its button when randomUUID is ${availability}`, async () => { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: availability === "absent" ? {} + : { randomUUID: () => { throw new Error("insecure context"); } } }); + rolesBody = { + ...(rolesBody as Record), + history: [ + { at: "2026-10-22T00:09:35.000Z", batch: "apply-all", role: "librarian", before: { model: null, effort: "medium" }, after: { model: "xai/grok-4.5", effort: "low" } }, + { at: "2026-10-22T00:09:34.000Z", batch: "apply-all", role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "low" } }, + ], + }; + await mount(); + const restore = () => container.querySelector(".lazycodex-role-history > ul > li button") as HTMLButtonElement; + await act(async () => { restore().click(); }); + await settle(); + expect(puts).toHaveLength(2); + expect(puts.map(put => (put.body as { batch?: string }).batch)).toEqual([undefined, "server-1"]); + expect(container.textContent).toContain("Restored 2 role(s)"); + expect(restore().disabled).toBe(false); + expect(restore().textContent).toBe("Restore"); + }); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 219928782e9..3981855e05a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1801,7 +1801,7 @@ "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", - "codex-agent-role-routes.test.ts": "server", + "codex-agent-role-routes.test.ts": "server", "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", diff --git a/src/codex/role-model-history.ts b/src/codex/role-model-history.ts new file mode 100644 index 00000000000..14353361709 --- /dev/null +++ b/src/codex/role-model-history.ts @@ -0,0 +1,253 @@ +/** + * Every role model write the omo tab and `ocx agent roles set` make, so a role can be put back. + * The integration journal cannot hold these: it snapshots one client config per operation, and a + * role write spans `$CODEX_HOME/agents/.toml` and LazyCodex's omo.jsonc mirror. So the + * values are recorded rather than bytes, and a restore is an ordinary write of the earlier values. + * Writes made in one action (auto-assign's Apply all) share a batch and are restored together. + * The server hands out batch ids and a write joins a batch only while the history still holds it whole, + * so an id compaction dropped can never start over and pass for the beginning of a batch. Every row + * carries its number in its batch, which keeps the cap durable and lets a read tell a whole batch + * from a cut one, which is never listed or restored. + * The file sits under the opencodex config directory, which outlives a switch of Codex home, so every + * row names the canonical Codex home it was written in and only that home's rows are listed or restored. + * The file is bounded three ways: a batch holds at most ROLE_HISTORY_LIMIT rows, an append that takes + * the file past ROLE_HISTORY_MAX_BYTES rewrites it to its newest whole batches, and a read looks at + * no more than that many bytes and ROLE_HISTORY_MAX_ROWS rows from the end. + */ +import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { ensureDir, integrationsDir } from "../integrations/ownership"; + +export interface RoleSetting { + readonly model: string | null; + readonly effort: string | null; +} + +export interface RoleModelChange { + readonly at: string; + readonly batch: string; + readonly role: string; + readonly before: RoleSetting; + readonly after: RoleSetting; +} + +interface StoredRoleModelChange extends RoleModelChange { + readonly codexHome: string; + /** This row's number in its batch, from 1: a batch is whole when it holds rows 1 to its highest. */ + readonly n: number; + /** The batch this row's write restored, so compaction keeps that batch while the Restore is kept. */ + readonly restores?: string; +} + +/** The batch a write is recorded in and its row number there. */ +export interface BatchSlot { + readonly batch: string; + readonly n: number; +} + +/** Rows one listing returns, and rows one batch may hold, so a whole batch always fits a listing. */ +export const ROLE_HISTORY_LIMIT = 50; +export const ROLE_HISTORY_MAX_BYTES = 256 * 1024; +export const ROLE_HISTORY_MAX_ROWS = 1000; +const BATCH_PATTERN = /^[A-Za-z0-9-]{1,64}$/; + +function historyPath(dir: string): string { + return join(dir, "codex-agent-roles.jsonl"); +} + +export function isRoleHistoryBatch(value: unknown): value is string { + return typeof value === "string" && BATCH_PATTERN.test(value); +} + +/** `codexHome` is the canonical path `getCodexHome()` returns, so two spellings of one home match. */ +export function recordRoleModelChange(codexHome: string, change: RoleModelChange & { readonly n: number; readonly restores?: string }, dir: string = integrationsDir()): void { + ensureDir(historyPath(dir)); + const stored: StoredRoleModelChange = { ...change, codexHome }; + // Capture the complete source before this append can move its first row outside either tail limit. + const tail = tailRows(historyPath(dir)); + // An interrupted JSONL suffix must not swallow the first record returned after restart. + appendFileSync(historyPath(dir), `${tail.unterminated ? "\n" : ""}${JSON.stringify(stored)}\n`, { encoding: "utf8", mode: 0o600 }); + if (statSync(historyPath(dir)).size <= ROLE_HISTORY_MAX_BYTES && tail.lines < ROLE_HISTORY_MAX_ROWS) return; + try { + compact(dir, wholeBatches([stored, ...tail.rows])); + } catch (cause) { + // Reads stay bounded without the trim; the next append retries it. + throw new Error("recorded the change but could not trim the history file", { cause }); + } +} + +/** + * Rewrite the file to its newest rows within half the byte budget and the row budget, so the next + * compaction is many saves away. A batch the cut falls inside is dropped whole, for every Codex home, + * so no home is left with part of an Apply all to restore. A kept Restore keeps the batch it restores + * whole when the Restore and its sources fit both read limits; protected rows displace older batches. + */ +function compact(dir: string, rows: StoredRoleModelChange[]): void { + const batches = new Map(); + for (const row of rows) { + const key = batchKey(row); + batches.set(key, [...(batches.get(key) ?? []), row]); + } + const keep = new Set(); + let bytes = 0; + let count = 0; + for (const key of batches.keys()) { + if (keep.has(key)) continue; + const needed = new Set([key]); + // Include source dependencies before admitting a batch, so they share its byte and row budgets. + for (const dependency of needed) { + for (const row of batches.get(dependency) ?? []) { + if (row.restores) { + const source = batchKey({ ...row, batch: row.restores }); + if (batches.has(source) && !keep.has(source)) needed.add(source); + } + } + } + const added = rows.filter(row => needed.has(batchKey(row))); + const addedBytes = added.reduce((sum, row) => sum + Buffer.byteLength(JSON.stringify(row)) + 1, 0); + if (bytes + addedBytes > ROLE_HISTORY_MAX_BYTES || count + added.length > ROLE_HISTORY_MAX_ROWS) break; + // Ordinary batches stay within the half-budget target; Restore sources may use the headroom. + if (bytes + addedBytes > ROLE_HISTORY_MAX_BYTES / 2 && !added.some(row => row.restores)) break; + for (const dependency of needed) keep.add(dependency); + bytes += addedBytes; + count += added.length; + if (bytes >= ROLE_HISTORY_MAX_BYTES / 2) break; + } + const whole = rows.filter(row => keep.has(batchKey(row))).reverse(); + atomicWriteFile(historyPath(dir), whole.map(row => `${JSON.stringify(row)}\n`).join("")); +} + +function batchKey(row: Pick): string { + return `${row.codexHome}\n${row.batch}`; +} + +function isSetting(value: unknown): value is RoleSetting { + const setting = value as RoleSetting | null; + return typeof setting === "object" && setting !== null + && (setting.model === null || typeof setting.model === "string") + && (setting.effort === null || typeof setting.effort === "string"); +} + +function parseRow(line: string): StoredRoleModelChange | null { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return null; + } + const change = value as StoredRoleModelChange | null; + return typeof change === "object" && change !== null && typeof change.codexHome === "string" + && typeof change.at === "string" && typeof change.role === "string" + && isRoleHistoryBatch(change.batch) && isSetting(change.before) && isSetting(change.after) + && Number.isSafeInteger(change.n) && change.n >= 1 + && (change.restores === undefined || isRoleHistoryBatch(change.restores)) + ? change + : null; +} + +/** + * Every Codex home's rows, newest first, from at most the last ROLE_HISTORY_MAX_BYTES of the file and + * at most ROLE_HISTORY_MAX_ROWS of them. When the read starts mid-file, its first partial line is + * skipped; a batch that continues above the read is left for `wholeBatches` to drop. + */ +function tailRows(path: string): { rows: StoredRoleModelChange[]; lines: number; unterminated: boolean } { + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { rows: [], lines: 0, unterminated: false }; + throw error; + } + let text: string; + try { + const stat = fstatSync(fd); + // Windows opens a directory for reading, so the type is checked rather than left to the read. + if (!stat.isFile()) throw new Error(`${path} is not a regular file`); + const size = stat.size; + const start = Math.max(0, size - ROLE_HISTORY_MAX_BYTES - 1); + const bytes = Buffer.alloc(size - start); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, start + length); + if (count === 0) break; + length += count; + } + const read = bytes.subarray(0, length); + text = (start > 0 ? read.subarray(read.indexOf(10) + 1) : read).toString("utf8"); + } finally { + closeSync(fd); + } + const lines = text.split("\n").filter(line => line.trim()).slice(-ROLE_HISTORY_MAX_ROWS); + const rows = lines.flatMap(line => { + const row = parseRow(line); + return row ? [row] : []; + }).reverse(); + return { rows, lines: lines.length, unterminated: text.length > 0 && !text.endsWith("\n") }; +} + +/** + * The rows of every batch that holds each of its rows 1 to its highest, so a batch the read or a + * compaction cut into is never offered as if it were whole. + */ +function wholeBatches(rows: StoredRoleModelChange[]): StoredRoleModelChange[] { + const numbers = new Map>(); + const highest = new Map(); + for (const row of rows) { + const key = batchKey(row); + numbers.set(key, (numbers.get(key) ?? new Set()).add(row.n)); + highest.set(key, Math.max(highest.get(key) ?? 0, row.n)); + } + return rows.filter(row => numbers.get(batchKey(row))!.size === highest.get(batchKey(row))); +} + +function forHome(rows: StoredRoleModelChange[], codexHome: string): RoleModelChange[] { + return rows.flatMap(({ codexHome: home, n: _n, restores: _restores, ...change }) => home === codexHome ? [change] : []); +} + +/** + * This Codex home's changes, newest first, in whole batches and never more than `limit` rows: batches + * are taken newest first until the next would not fit, so Restore never puts back part of an Apply all. + * A batch larger than `limit` (written before batches were capped) can never be listed whole and is + * skipped. A torn or foreign line, and a row another Codex home wrote, is skipped, the way the + * integration journal reads its own. + */ +export function listRoleModelChanges(codexHome: string, limit: number = ROLE_HISTORY_LIMIT, dir: string = integrationsDir()): RoleModelChange[] { + const newestFirst = forHome(wholeBatches(tailRows(historyPath(dir)).rows), codexHome); + const sizes = new Map(); + for (const change of newestFirst) sizes.set(change.batch, (sizes.get(change.batch) ?? 0) + 1); + const listed = new Set(); + let count = 0; + for (const [batch, size] of sizes) { + if (size > limit) continue; + if (count + size > limit) break; + listed.add(batch); + count += size; + } + return newestFirst.filter(change => listed.has(change.batch)); +} + +/** + * Where a write in this Codex home is recorded. Without `batch` it starts a new batch under a fresh id. + * With one, it joins that batch only while this home's retained history holds the batch whole, and is + * refused once the batch holds ROLE_HISTORY_LIMIT rows. An id compaction dropped, or one the server + * never handed out, is closed: it cannot take a row and pass for the start of a batch. + */ +export function claimBatchRow(codexHome: string, batch: string | undefined, dir: string = integrationsDir()): BatchSlot | "batch_closed" | "batch_full" { + if (batch === undefined) return { batch: crypto.randomUUID(), n: 1 }; + const held = wholeBatches(tailRows(historyPath(dir)).rows).filter(row => row.codexHome === codexHome && row.batch === batch); + if (held.length === 0) return "batch_closed"; + return held.length >= ROLE_HISTORY_LIMIT ? "batch_full" : { batch, n: held.length + 1 }; +} + +/** + * The setting a Restore of `batch` may put back on `role` in this Codex home: the role's earliest + * `before` in that batch, or null when this home's retained history has no such change. The server + * checks a restore against it, so a dashboard still showing another home's history cannot write + * that home's earlier models here. It reads every retained batch rather than the 50-row listing, + * because a Restore's own rows push the batch it restores out of that listing before it finishes. + */ +export function restorableSetting(codexHome: string, batch: string, role: string, dir: string = integrationsDir()): RoleSetting | null { + const changes = forHome(wholeBatches(tailRows(historyPath(dir)).rows), codexHome).filter(change => change.batch === batch && change.role === role); + return changes.at(-1)?.before ?? null; +} diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index a3d8f813f00..d9d55614dce 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -22,11 +22,12 @@ function lazycodexNotDetected(ctx: ManagementContext): Response { export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; - const [{ getCodexHome }, roles, omo, { detectLazyCodex }] = await Promise.all([ + const [{ getCodexHome }, roles, omo, { detectLazyCodex }, history] = await Promise.all([ import("../../codex/paths"), import("../../codex/agent-role-models"), import("../../clients/omo-role-models"), import("../../clients/lazycodex"), + import("../../codex/role-model-history"), ]); if (url.pathname === "/api/codex-agent-roles" && req.method === "GET") { @@ -35,6 +36,15 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (!lazycodex.detected) return jsonResponse({ lazycodex, omoJsonc: null, roles: [] }, 200, req, config); const omoState = omo.readOmoRoleModels(omo.omoJsoncPath()); const omoModels = omoState.state === "present" ? omoState.models : {}; + // History is optional: a file that cannot be read costs the Restore list, never role editing. + let changes: import("../../codex/role-model-history").RoleModelChange[] = []; + let historyUnavailable = false; + try { + changes = history.listRoleModelChanges(codexHome); + } catch (error) { + console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); + historyUnavailable = true; + } return jsonResponse({ lazycodex, omoJsonc: { state: omoState.state }, @@ -43,6 +53,8 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis effort: roles.readCodexAgentRoleEffort(entry.role, codexHome), omoJsoncModel: omoModels[entry.role] ?? null, })), + history: changes, + ...(historyUnavailable ? { historyUnavailable } : {}), }, 200, req, config); } @@ -88,7 +100,7 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } catch { return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); } - let body: { model?: unknown; effort?: unknown }; + let body: { model?: unknown; effort?: unknown; batch?: unknown; restores?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { @@ -102,12 +114,58 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis if (body?.model === undefined && body?.effort === undefined) { return jsonResponse({ error: "send a model, an effort, or both", code: "empty_update" }, 400, req, config); } + if (body.batch !== undefined && !history.isRoleHistoryBatch(body.batch)) { + return jsonResponse({ error: "batch must be 1 to 64 letters, digits or dashes", code: "invalid_batch" }, 400, req, config); + } + if (body.restores !== undefined && !history.isRoleHistoryBatch(body.restores)) { + return jsonResponse({ error: "restores must be 1 to 64 letters, digits or dashes", code: "invalid_restores" }, 400, req, config); + } + // A write without `batch` starts a batch and the response names it; a later write joins it only + // while it is this home's newest batch, up to the listing size. History is optional: a file that + // cannot be read does not block saves, and such a save is simply not recorded. + let slot: import("../../codex/role-model-history").BatchSlot | null = null; + try { + const claimed = history.claimBatchRow(codexHome, body.batch); + if (claimed === "batch_closed") { + return jsonResponse({ error: "this batch takes no more changes; leave batch out to start a new one", code: "batch_closed" }, 409, req, config); + } + if (claimed === "batch_full") { + return jsonResponse({ error: `a batch holds at most ${history.ROLE_HISTORY_LIMIT} changes`, code: "batch_full" }, 409, req, config); + } + slot = claimed; + } catch (error) { + console.error(`[codex-agent-roles] could not read the change history: ${String(error)}`); + } let model: string | undefined; let effort: string | null | undefined; let toml: { status: "written" | "unchanged" }; + let before: import("../../codex/role-model-history").RoleSetting = { model: null, effort: null }; try { model = body?.model === undefined ? undefined : roles.validateAgentRoleModel(body.model); effort = body?.effort === undefined ? undefined : body.effort === null ? null : roles.validateAgentRoleEffort(body.effort); + } catch (error) { + if (error instanceof roles.AgentRoleModelError) return jsonResponse({ error: error.message, code: error.code }, 400, req, config); + throw error; + } + // A Restore names the batch it puts back, and only this Codex home's history can vouch for it: the + // write must be that role's earliest `before` in the batch, so a stale page cannot carry models + // across homes. A role that had no model pin is restored without a model, since a save keeps a pin. + if (typeof body.restores === "string") { + let allowed: import("../../codex/role-model-history").RoleSetting | null; + try { + allowed = history.restorableSetting(codexHome, body.restores, role); + } catch { + allowed = null; + } + if (!allowed || model !== (allowed.model ?? undefined) || effort !== allowed.effort) { + return jsonResponse({ error: "this Codex home's change history has no such change to restore", code: "restore_not_in_history" }, 409, req, config); + } + } + try { + before = { + model: roles.listCodexAgentRoleModels(codexHome).find(entry => entry.role === role)?.model ?? null, + effort: roles.readCodexAgentRoleEffort(role, codexHome), + }; toml = roles.writeCodexAgentRoleModel(role, model, codexHome, effort); } catch (error) { if (error instanceof roles.AgentRoleModelError) { @@ -117,6 +175,24 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis // Filesystem and ownership errors carry absolute paths and UIDs; keep them out of the response. return jsonResponse({ error: "could not write the role file", code: "write_failed" }, 500, req, config); } + let recorded: string | undefined; + if (toml.status === "written" && slot) { + try { + history.recordRoleModelChange(codexHome, { + at: new Date().toISOString(), + batch: slot.batch, + n: slot.n, + role, + before, + after: { model: model ?? before.model, effort: effort === undefined ? before.effort : effort }, + ...(typeof body.restores === "string" ? { restores: body.restores } : {}), + }); + recorded = slot.batch; + } catch (error) { + // The write already stands; a lost history row only costs the Restore button for it. + console.error(`[codex-agent-roles] could not record the change: ${String(error)}`); + } + } // The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow. let omoStatus: ReturnType | "write_failed"; @@ -133,5 +209,6 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis ...(effort !== undefined ? { effort } : {}), toml, omoJsonc: { status: omoStatus }, + ...(recorded ? { batch: recorded } : {}), }, 200, req, config); } diff --git a/structure/subagents.md b/structure/subagents.md index 0cda574b70e..55a5d7c26bb 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -301,6 +301,7 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f - The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The target must be a regular file owned by the running user; the replacement is atomic and does not follow a symbolic link. +- Every PUT that writes the role TOML appends one row to `codex-agent-roles.jsonl` under `integrationsDir()` (`src/codex/role-model-history.ts`): time, `batch`, role, model and effort before and after, and the canonical Codex home (`getCodexHome()`) it wrote. An unchanged save appends nothing, and a failed append is logged without failing the write. Before appending, the writer separates an unterminated suffix with a newline: torn JSON remains skippable and a complete final row remains valid, so a restart does not swallow a newly issued batch's first row. The server hands out batch ids: a write without the optional body field `batch` starts a new batch, and a recorded write's response names it as `batch`; a later write that sends that id (1 to 64 letters, digits or dashes, else 400 `invalid_batch`) joins it, so auto-assign's Apply all and a Restore send the id from their first write's response with every later one. A write joins a batch only while the current home's retained history holds it whole, else 409 `batch_closed`, so an id compaction dropped, or one the server never issued, cannot start over and pass for the beginning of a batch; a batch holds at most 50 rows, else 409 `batch_full`; both are refused before the role file is touched. Each row stores its number in its batch, which keeps that cap durable and lets every read drop a batch missing any of its rows, so a cut batch is never listed or restored. `GET /api/codex-agent-roles` returns the current Codex home's newest whole batches as `history`, never more than 50 rows, when LazyCodex is detected (a batch over 50 rows from an older file is skipped); rows another Codex home wrote are never listed, so a proxy restarted against a different home starts with an empty list. The file is bounded: before appending, the writer captures the last 256 KiB and 1000 rows. An append that crosses either limit compacts that snapshot plus the new row to the newest whole batches, targeting 128 KiB. A kept Restore and its complete source batches are budgeted together, displacing unrelated older batches and using up to 256 KiB and 1000 rows in total when needed. If that group cannot fit both limits, it expires rather than exceeding the bounded read window. A history file it cannot read sets `historyUnavailable: true` with an empty list, and the role list and saves keep working. The dashboard restores a batch by writing each role's earliest `before` through this same PUT under a new batch, naming the restored batch in `restores`; the server refuses such a write with 409 `restore_not_in_history` unless the current home's retained history (every whole batch the file holds, not only the 50 listed rows) holds that batch for that role with exactly those earlier values, so leaving the 50-row listing alone does not invalidate a Restore. Compaction preserves a retained Restore's source from the pre-append snapshot while their combined rows fit both retention limits; this is bounded history, not an indefinite reservation against later writes. - On `PUT /api/codex-agent-roles/{role}`, `effort` is optional: omitted keeps the role's `model_reasoning_effort`, a level inserts or replaces it, and JSON `null` removes its root line and trailing comment. Only a one-line string value can be removed; a multiline or non-string diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 17a4c2a088f..f93ea8028e4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1279,6 +1279,7 @@ "codex-agent-role-models.test.ts": "routing", "codex-role-sizing.test.ts": "routing", "codex-role-auto-assign.test.ts": "routing", "omo-role-models.test.ts": "clients", "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", + "codex-role-history-recovery.test.ts": "server", "codex-role-auto-assign-routes.test.ts": "server", "local-chat-completion.test.ts": "lib", "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations-unattended.test.ts": "clients", "sync-client-integrations.test.ts": "clients", "synced-state-location.test.ts": "lib", diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts index f399c9f1f13..8cd35ae54f7 100644 --- a/tests/server/codex-agent-role-routes.test.ts +++ b/tests/server/codex-agent-role-routes.test.ts @@ -9,7 +9,7 @@ import { ManagementRequest as Request } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const ROLE = 'name = "explorer"\ndeveloper_instructions = """\nmodel = "x"\n"""\nmodel = "gpt-5.5"\n'; -const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, OPENCODEX_HOME: process.env.OPENCODEX_HOME }; let root = ""; function restore(name: keyof typeof saved): void { @@ -27,23 +27,25 @@ beforeEach(() => { process.env.CODEX_HOME = join(root, "codex"); process.env.HOME = join(root, "home"); process.env.USERPROFILE = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "ocx"); }); afterEach(() => { restore("CODEX_HOME"); restore("HOME"); restore("USERPROFILE"); + restore("OPENCODEX_HOME"); removeTreeWithRetry(root); }); const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; const DETECTED = { detected: true, pluginEnabled: true, pluginInstalled: true }; -function installLazyCodex(): void { - const plugin = join(root, "codex", "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); +function installLazyCodex(home = join(root, "codex")): void { + const plugin = join(home, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); mkdirSync(plugin, { recursive: true }); writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); - writeFileSync(join(root, "codex", "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); + writeFileSync(join(home, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); } async function call(path: string, init?: RequestInit): Promise<{ status: number; body: Record }> { @@ -57,16 +59,264 @@ function put(role: string, model: unknown) { } describe("/api/codex-agent-roles", () => { + test("lists and restores only the history of the Codex home the proxy runs against", async () => { + const homeA = join(root, "codex"); + const homeB = join(root, "codex-b"); + mkdirSync(join(homeB, "agents"), { recursive: true }); + writeFileSync(join(homeB, "agents", "explorer.toml"), ROLE); + installLazyCodex(homeB); + const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); + const written = await write({ model: "xai/grok-4.5" }); + expect(written.status).toBe(200); + const homeABatch = written.body.batch as string; + const restore = { model: "gpt-5.5", effort: null, restores: homeABatch }; + + // The proxy restarts against a second home with a role of the same name. + process.env.CODEX_HOME = homeB; + expect((await call("/api/codex-agent-roles")).body.history).toEqual([]); + expect(await write(restore)).toMatchObject({ status: 409, body: { code: "restore_not_in_history" } }); + expect(readFileSync(join(homeB, "agents", "explorer.toml"), "utf8")).toBe(ROLE); + + process.env.CODEX_HOME = homeA; + const history = (await call("/api/codex-agent-roles")).body.history as Array>; + expect(history.map(change => change.batch)).toEqual([homeABatch]); + expect(history[0]).not.toHaveProperty("codexHome"); + expect(history[0]).not.toHaveProperty("n"); + expect(await write({ ...restore, model: "a/other" })).toMatchObject({ status: 409, body: { code: "restore_not_in_history" } }); + expect((await write(restore)).status).toBe(200); + expect(readFileSync(join(homeA, "agents", "explorer.toml"), "utf8")).toBe(ROLE); + }); + + function historyRow(batch: string, role: string, model = "m", n = 1): import("../../src/codex/role-model-history").RoleModelChange & { n: number } { + return { at: "2026-10-24T00:00:00.000Z", batch, n, role, before: { model: null, effort: null }, after: { model, effort: null } }; + } + + test("lists whole batches and never more than 50 rows, even when one batch id was reused past its cap", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("a", `a-${index}`, "m", index + 1), dir); + for (let index = 0; index < 30; index++) recordRoleModelChange("home", historyRow("b", `b-${index}`, "m", index + 1), dir); + expect(listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir).map(change => change.batch)).toEqual(Array(30).fill("b")); + // A file written before the batch cap can hold one batch larger than the whole response. + for (let index = 0; index < 120; index++) recordRoleModelChange("home", historyRow("reused", `r-${index}`, "m", index + 1), dir); + const history = listRoleModelChanges("home", ROLE_HISTORY_LIMIT, dir); + expect(history.length).toBeLessThanOrEqual(ROLE_HISTORY_LIMIT); + expect(history.map(change => change.batch)).toEqual(Array(30).fill("b")); + }); + + test("a write into a batch that already holds 50 rows is refused before the role file changes", async () => { + const { recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + for (let index = 0; index < ROLE_HISTORY_LIMIT; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `role-${index}`, "m", index + 1), dir); + const refused = await call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify({ model: "xai/grok-4.5", batch: "full" }) }); + expect(refused).toMatchObject({ status: 409, body: { code: "batch_full" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect((await put("explorer", "xai/grok-4.5")).status).toBe(200); + }); + + /** The batch the server recorded for the newest write. */ + async function newestBatch(): Promise { + return ((await call("/api/codex-agent-roles")).body.history as Array<{ batch: string }>)[0]!.batch; + } + + // explorer pinned gpt-5.5 with no effort; librarian inherited its model and held effort high. + async function seedRestorable(dir: string, batch: string, explorerAfter: string, librarianAfter: string, start = 1): Promise { + const { recordRoleModelChange } = await import("../../src/codex/role-model-history"); + const home = join(root, "codex"); + recordRoleModelChange(home, { ...historyRow(batch, "explorer", explorerAfter, start), before: { model: "gpt-5.5", effort: null } }, dir); + recordRoleModelChange(home, { ...historyRow(batch, "librarian", librarianAfter, start + 1), before: { model: null, effort: "high" } }, dir); + } + + test("a Restore of a full 50-row batch writes every role, though its own rows push that batch out of the listing", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_LIMIT } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + inheritingRole(`model_reasoning_effort = "low"\n${INHERITING}`); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE.replace('model = "gpt-5.5"', 'model = "a/now"')); + for (let index = 0; index < ROLE_HISTORY_LIMIT - 2; index++) recordRoleModelChange(join(root, "codex"), historyRow("full", `gone-${index}`, "m", index + 1), dir); + await seedRestorable(dir, "full", "a/now", "a/now", ROLE_HISTORY_LIMIT - 1); + expect(listRoleModelChanges(join(root, "codex"), ROLE_HISTORY_LIMIT, dir)).toHaveLength(ROLE_HISTORY_LIMIT); + const restore = (role: string, body: Record) => roleCall(role, { ...body, restores: "full" }); + expect((await restore("explorer", { model: "gpt-5.5", effort: null })).status).toBe(200); + expect((await restore("librarian", { effort: "high" })).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect(readFileSync(join(root, "codex", "agents", "librarian.toml"), "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); + }); + + test("a compaction in the middle of a Restore keeps the batch being restored until every role is back", async () => { + const { recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "ocx", "integrations"); + inheritingRole(`model_reasoning_effort = "low"\n${INHERITING}`); + // Older batches fill most of the byte budget, and the batch to restore holds two wide rows. + for (let index = 0; index < 10; index++) recordRoleModelChange(join(root, "codex"), historyRow(`old-${index}`, "explorer", "o".repeat(10_000)), dir); + await seedRestorable(dir, "wide", "s".repeat(60_000), "t".repeat(60_000)); + // The first restored row records a wide current model, so its append takes the file past the budget. + const current = "c".repeat(50_000); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE.replace('model = "gpt-5.5"', `model = "${current}"`)); + const path = join(dir, "codex-agent-roles.jsonl"); + const before = fs.statSync(path).size; + expect(before + current.length).toBeGreaterThan(ROLE_HISTORY_MAX_BYTES); + const restore = (role: string, body: Record) => roleCall(role, { ...body, restores: "wide" }); + expect((await restore("explorer", { model: "gpt-5.5", effort: null })).status).toBe(200); + expect(fs.statSync(path).size).toBeLessThan(before); + expect((await restore("librarian", { effort: "high" })).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "librarian.toml"), "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); + }); + + test("120 writes into the batch the server handed out stop at 50 and never lose the batch's first value", async () => { + const { ROLE_HISTORY_LIMIT, restorableSetting } = await import("../../src/codex/role-model-history"); + const wide = (index: number) => `m${index}-${"w".repeat(4000)}`; + const started = await roleCall("explorer", { model: wide(0) }); + expect(started.status).toBe(200); + const batch = await newestBatch(); + let accepted = 1; + const refusals = new Set(); + for (let index = 1; index < 120; index++) { + const result = await roleCall("explorer", { model: wide(index), batch }); + if (result.status === 200) accepted += 1; + else refusals.add(`${result.status} ${String(result.body.code)}`); + } + expect(accepted).toBeLessThanOrEqual(ROLE_HISTORY_LIMIT); + expect([...refusals].every(refusal => refusal === "409 batch_full" || refusal === "409 batch_closed")).toBe(true); + // Wide rows close the batch early by compaction; either way only a whole batch is listed or restored. + const listed = ((await call("/api/codex-agent-roles")).body.history as Array<{ batch: string }>).filter(change => change.batch === batch); + expect([0, accepted]).toContain(listed.length); + expect([null, "gpt-5.5"]).toContain(restorableSetting(join(root, "codex"), batch as string, "explorer")?.model ?? null); + }, 60_000); + + test("a batch id compaction dropped, or one the server never handed out, takes no rows, so Restore never offers a value from inside a batch", async () => { + const { recordRoleModelChange, restorableSetting } = await import("../../src/codex/role-model-history"); + const first = await roleCall("explorer", { model: "middle" }); + expect(first.status).toBe(200); + const batch = await newestBatch(); + // Wide writes in other batches compact the file past that first batch. + const dir = join(root, "ocx", "integrations"); + for (let index = 0; index < 80; index++) recordRoleModelChange(join(root, "codex"), historyRow(`other-${index}`, "librarian", "w".repeat(4000)), dir); + expect(restorableSetting(join(root, "codex"), batch, "explorer")).toBeNull(); + expect(await roleCall("explorer", { model: "new", batch })).toMatchObject({ status: 409, body: { code: "batch_closed" } }); + expect(await roleCall("explorer", { model: "new", batch: "client-made" })).toMatchObject({ status: 409, body: { code: "batch_closed" } }); + const history = (await call("/api/codex-agent-roles")).body.history as Array<{ role: string }>; + expect(history.filter(change => change.role === "explorer")).toEqual([]); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "middle"'); + }); + + test("a batch reused after another batch wrote keeps its first value as the one Restore offers", async () => { + // An interleaved fragment: rows 2 and 3 of a batch whose row 1 (the original value) is gone. + const { listRoleModelChanges, recordRoleModelChange, restorableSetting } = await import("../../src/codex/role-model-history"); + const dir = join(root, "fragment"); + recordRoleModelChange("home", { ...historyRow("cut", "explorer", "late", 2), before: { model: "middle", effort: null } }, dir); + recordRoleModelChange("home", historyRow("whole", "worker", "m", 1), dir); + recordRoleModelChange("home", { ...historyRow("cut", "explorer", "latest", 3), before: { model: "late", effort: null } }, dir); + expect(listRoleModelChanges("home", 50, dir).map(change => change.batch)).toEqual(["whole"]); + expect(restorableSetting("home", "cut", "explorer", dir)).toBeNull(); + + inheritingRole(); + expect((await roleCall("explorer", { model: "middle" })).status).toBe(200); + const original = await newestBatch(); + expect((await roleCall("librarian", { effort: "low" })).status).toBe(200); + expect(await newestBatch()).not.toBe(original); + expect(await roleCall("explorer", { model: "new", batch: original })).toMatchObject({ status: 200, body: { batch: original } }); + const history = (await call("/api/codex-agent-roles")).body.history as Array<{ role: string; before: { model: string | null } }>; + expect(history.filter(change => change.role === "explorer").map(change => change.before.model)).toEqual(["middle", "gpt-5.5"]); + expect((await roleCall("explorer", { model: "middle", effort: null, restores: original })).status).toBe(409); + expect((await roleCall("explorer", { model: "gpt-5.5", effort: null, restores: original })).status).toBe(200); + }); + + test("keeps the history file within its byte budget by dropping the oldest whole batches", async () => { + const { listRoleModelChanges, recordRoleModelChange, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + const wide = "m".repeat(4000); + let batches = 0; + for (let written = 0; written < ROLE_HISTORY_MAX_BYTES * 3; written += 3 * wide.length, batches += 1) { + for (let role = 0; role < 3; role++) recordRoleModelChange("home", historyRow(`batch-${batches}`, `role-${role}`, wide, role + 1), dir); + } + const path = join(dir, "codex-agent-roles.jsonl"); + expect(fs.statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + const rows = readFileSync(path, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line) as { batch: string }); + const sizes = new Map(); + for (const row of rows) sizes.set(row.batch, (sizes.get(row.batch) ?? 0) + 1); + expect([...sizes.values()].every(size => size === 3)).toBe(true); + expect(rows.at(-1)!.batch).toBe(`batch-${batches - 1}`); + expect(sizes.has("batch-0")).toBe(false); + expect(listRoleModelChanges("home", 50, dir)[0]!.batch).toBe(`batch-${batches - 1}`); + }); + + test("reads only the tail of an oversized history file", async () => { + const { listRoleModelChanges, ROLE_HISTORY_MAX_BYTES } = await import("../../src/codex/role-model-history"); + const dir = join(root, "history"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "codex-agent-roles.jsonl"); + const line = (batch: string, role: string) => `${JSON.stringify({ ...historyRow(batch, role), codexHome: "home" })}\n`; + // An oversized file from before retention, or one a failed compaction left behind. + writeFileSync(path, line("ancient", "explorer").repeat(Math.ceil((ROLE_HISTORY_MAX_BYTES * 4) / line("ancient", "explorer").length)) + line("newest", "explorer")); + const nativeReadFile = fs.readFileSync; + const nativeRead = fs.readSync; + let wholeReads = 0; + let bytesRead = 0; + const readFile = spyOn(fs, "readFileSync").mockImplementation(((target: fs.PathOrFileDescriptor, options?: unknown) => { + if (String(target) === path) wholeReads += 1; + return nativeReadFile(target, options as BufferEncoding); + }) as never); + const read = spyOn(fs, "readSync").mockImplementation(((...args: Parameters) => { + const count = (nativeRead as (...rest: unknown[]) => number)(...args); + bytesRead += count; + return count; + }) as never); + try { + expect(listRoleModelChanges("home", 50, dir)[0]!.batch).toBe("newest"); + } finally { + readFile.mockRestore(); + read.mockRestore(); + } + expect(wholeReads).toBe(0); + expect(bytesRead).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES + 1); + }); + + test("an unreadable history file leaves the role list and role saves working", async () => { + // A directory where the history file belongs makes every read of it fail (EISDIR) on every platform. + mkdirSync(join(root, "ocx", "integrations", "codex-agent-roles.jsonl"), { recursive: true }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + const listed = await call("/api/codex-agent-roles"); + expect(listed.status).toBe(200); + expect(listed.body).toMatchObject({ roles: [{ role: "explorer", model: "gpt-5.5" }], history: [], historyUnavailable: true }); + expect((await put("explorer", "xai/grok-4.5")).status).toBe(200); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "xai/grok-4.5"'); + } finally { + error.mockRestore(); + } + }); + + test("records each written change with its earlier values, newest first, and skips an unchanged save", async () => { + const write = (body: Record) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); + const first = await write({ model: "xai/grok-4.5", effort: "high" }); + expect(first.status).toBe(200); + const batch = first.body.batch as string; + expect(batch).toMatch(/^[0-9a-f-]{36}$/); + const unchanged = await write({ model: "xai/grok-4.5", effort: "high", batch }); + expect(unchanged.body).toMatchObject({ toml: { status: "unchanged" } }); + expect(unchanged.body).not.toHaveProperty("batch"); + await write({ effort: null }); + const history = (await call("/api/codex-agent-roles")).body.history as Record[]; + expect(history.map(({ batch, role, before, after }) => ({ batch, role, before, after }))).toEqual([ + { batch: expect.stringMatching(/^[0-9a-f-]{36}$/), role: "explorer", before: { model: "xai/grok-4.5", effort: "high" }, after: { model: "xai/grok-4.5", effort: null } }, + { batch, role: "explorer", before: { model: "gpt-5.5", effort: null }, after: { model: "xai/grok-4.5", effort: "high" } }, + ]); + const refused = await write({ model: "gpt-5.5", batch: "../x" }); + expect(refused).toMatchObject({ status: 400, body: { code: "invalid_batch" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toContain('model = "xai/grok-4.5"'); + }); + test("round-trips a role model through the TOML and omo.jsonc", async () => { writeFileSync(join(root, "home", ".omo", "omo.jsonc"), '{ "[codex]": {} }\n'); expect((await call("/api/codex-agent-roles")).body).toEqual({ lazycodex: DETECTED, omoJsonc: { state: "present" }, roles: [{ role: "explorer", model: "gpt-5.5", effort: null, omoJsoncModel: null }], + history: [], }); const saved = await put("explorer", "xai/grok-4.5"); expect(saved.status).toBe(200); - expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE.replace('model = "gpt-5.5"', 'model = "xai/grok-4.5"')); expect((await call("/api/codex-agent-roles")).body.roles).toEqual([{ role: "explorer", model: "xai/grok-4.5", effort: null, omoJsoncModel: "xai/grok-4.5" }]); }); @@ -146,7 +396,7 @@ describe("/api/codex-agent-roles", () => { writeFileSync(omoPath, '{ "[codex]": { "agents": { "librarian": { "model": "omo/pick", "temperature": 0.2 } } } }\n'); const set = await roleCall("librarian", { effort: "high" }); expect(set.status).toBe(200); - expect(set.body).toEqual({ ok: true, role: "librarian", effort: "high", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(set.body).toEqual({ ok: true, role: "librarian", effort: "high", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(`model_reasoning_effort = "high"\n${INHERITING}`); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "omo/pick", temperature: 0.2, reasoning: "high" }); const listed = (await call("/api/codex-agent-roles")).body.roles as Array>; @@ -161,7 +411,7 @@ describe("/api/codex-agent-roles", () => { const cleared = await roleCall("librarian", { effort: null }); expect(cleared.status).toBe(200); - expect(cleared.body).toEqual({ ok: true, role: "librarian", effort: null, toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(cleared.body).toEqual({ ok: true, role: "librarian", effort: null, toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(INHERITING); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "omo/pick", temperature: 0.2 }); }); @@ -206,7 +456,7 @@ describe("/api/codex-agent-roles", () => { const omoPath = join(root, "home", ".omo", "omo.jsonc"); writeFileSync(omoPath, '{ "[codex]": { "agents": { "librarian": { "reasoning": "high" } } } }\n'); const saved = await put("librarian", "xai/grok-4.5"); - expect(saved.body).toEqual({ ok: true, role: "librarian", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(saved.body).toEqual({ ok: true, role: "librarian", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" }, batch: expect.any(String) }); expect(readFileSync(rolePath, "utf8")).toBe(`model = "xai/grok-4.5"\nmodel_reasoning_effort = "high"\n${INHERITING}`); expect(JSON.parse(readFileSync(omoPath, "utf8"))["[codex]"].agents.librarian).toEqual({ model: "xai/grok-4.5", reasoning: "high" }); }); @@ -274,6 +524,7 @@ describe("/api/codex-agent-roles", () => { lazycodex: DETECTED, omoJsonc: { state: "unreadable" }, roles: [{ role: "explorer", model: "gpt-5.5", effort: null, omoJsoncModel: null }], + history: [], }); const saved = await put("explorer", "m3"); expect(saved.body.toml).toEqual({ status: "written" }); diff --git a/tests/server/codex-role-history-recovery.test.ts b/tests/server/codex-role-history-recovery.test.ts new file mode 100644 index 00000000000..a207d8b57d7 --- /dev/null +++ b/tests/server/codex-role-history-recovery.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listRoleModelChanges, ROLE_HISTORY_MAX_BYTES, ROLE_HISTORY_MAX_ROWS } from "../../src/codex/role-model-history"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; +let root: string, home: string, dir: string, path: string; +const setting = (model: string) => ({ model, effort: null }); +const line = (row: object) => `${JSON.stringify(row)}\n`; +const row = (batch: string, n = 1, model = "f") => ({ at: "2026-10-27T00:00:00.000Z", batch, n, codexHome: home, role: "explorer", before: setting("initial"), after: setting(model) }); + +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-role-history-recovery-"))); + home = join(root, "codex"); + dir = join(root, "ocx", "integrations"); + path = join(dir, "codex-agent-roles.jsonl"); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(home, "agents"), { recursive: true }); + const plugin = join(home, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(home, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); + for (const role of ["explorer", "librarian"]) writeFileSync(join(home, "agents", `${role}.toml`), 'model = "initial"\n'); + process.env.CODEX_HOME = home; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.HOME = process.env.USERPROFILE = join(root, "user"); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); +}); + +async function put(role: string, body: object) { + const url = new URL(`http://localhost/api/codex-agent-roles/${role}`); + const response = (await handleManagementAPI(new ManagementRequest(url, { method: "PUT", body: JSON.stringify(body) }), url, config))!; + return { status: response.status, body: await response.json() as { batch: string } }; +} + +test.each(["byte", "row"] as const)("Restore retains its interleaved source at the %s tail boundary", async boundary => { + const started = await put("explorer", { model: "changed" }); + expect(started.status).toBe(200); + const source = started.body.batch; + let text = readFileSync(path, "utf8"); + const last = line({ ...row(source, 2, "changed"), role: "librarian" }); + if (boundary === "byte") { + let index = 0; + while (Buffer.byteLength(text + last) < ROLE_HISTORY_MAX_BYTES - 1500) text += line(row(`filler-${index++}`, 1, "f".repeat(200))); + const pad = row("pad", 1, ""); + pad.after.model = "p".repeat(ROLE_HISTORY_MAX_BYTES - 64 - Buffer.byteLength(text + last + line(pad))); + text += line(pad); + } else { + for (let index = 0; index < ROLE_HISTORY_MAX_ROWS - 2; index++) text += line({ ...row(`filler-${index}`), codexHome: "other" }); + } + writeFileSync(path, text); + expect((await put("librarian", { model: "changed", batch: source })).status).toBe(200); + if (boundary === "byte") expect(statSync(path).size).toBe(ROLE_HISTORY_MAX_BYTES - 64); + expect(listRoleModelChanges(home, 50, dir).filter(change => change.batch === source)).toHaveLength(2); + const first = await put("explorer", { model: "initial", effort: null, restores: source }); + expect(first.status).toBe(200); + expect(statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + expect(readFileSync(path, "utf8").trim().split("\n").length).toBeLessThanOrEqual(ROLE_HISTORY_MAX_ROWS); + expect((await put("librarian", { model: "initial", effort: null, restores: source, batch: first.body.batch })).status).toBe(200); + expect(readFileSync(join(home, "agents", "librarian.toml"), "utf8")).toContain('model = "initial"'); +}); + +test("protected source bytes displace unrelated batches within the read budget", async () => { + const started = await put("explorer", { model: "changed" }); + const source = started.body.batch; + const sourceRows = Array.from({ length: 50 }, (_, index) => line(row(source, index + 1, "s".repeat(3500)))).join(""); + let text = sourceRows; + let index = 0; + while (Buffer.byteLength(text) < ROLE_HISTORY_MAX_BYTES - 12000) text += line(row(`filler-${index++}`, 1, "f".repeat(500))); + writeFileSync(path, text); + writeFileSync(join(home, "agents", "explorer.toml"), `model = "${"c".repeat(15000)}"\n`); + expect((await put("explorer", { model: "initial", effort: null, restores: source })).status).toBe(200); + expect(statSync(path).size).toBeLessThanOrEqual(ROLE_HISTORY_MAX_BYTES); + expect(listRoleModelChanges(home, 1000, dir).filter(change => change.batch === source)).toHaveLength(50); +}); + +test.each(["torn", "complete"])("restart after an unterminated %s suffix leaves a new Apply all batch joinable", async suffix => { + const old = await put("explorer", { model: "before-restart" }); + const complete = readFileSync(path, "utf8"); + // Disk state left by an interrupted process; no in-memory batch state survives a restart. + writeFileSync(path, suffix === "torn" ? `${complete}{"at":"interrupted` : complete.trimEnd()); + const first = await put("explorer", { model: "apply-a" }); + expect(first.status).toBe(200); + expect(typeof first.body.batch).toBe("string"); + expect((await put("librarian", { model: "apply-b", batch: first.body.batch })).status).toBe(200); + const history = listRoleModelChanges(home, 50, dir); + expect(history.filter(change => change.batch === first.body.batch)).toHaveLength(2); + expect(history.filter(change => change.batch === old.body.batch)).toHaveLength(1); + expect(readFileSync(join(home, "agents", "librarian.toml"), "utf8")).toContain('model = "apply-b"'); +});