sessions, gui: Codex sessions made with another provider are moved to Codex's own from the Sessions page, and back (#887, cjyrainbow) — Codex's history lists only the sessions of the provider it uses now, so sessions made through CC Switch (model_provider "custom") vanished from it once Codex used magpie, though their files were still there and the Sessions page listed them. The Codex tab now reads each session's provider from its session_meta (cached with the rest) and the one Codex in that session's home uses (config.toml's model_provider, or its profile's; "openai" when none), says how many were made with another and which, and tags each. "Move to <provider>…" opens magpie's own dialog listing them, each ticked; Move rewrites only the model_provider string in each session's files (.jsonl and .jsonl.zst, in its own Codex home, so WSL's too, mtime kept) and in Codex's state_*.sqlite threads row: messages, id, title and archive are left as they are. A copy of the files goes to magpie's trash folder (trash/codex-provider, with a move.json) first, a failed rewrite is put back from it, and a session written to in the last minute is left alone and said so. Undo moves each back to the provider it had, byte for byte. Nothing is moved by itself. POST /api/sessions/codex-provider; en/zh/ja/de. TestMoveCodexProvider (listing, bytes, mtime, the DB row and nothing else, backup, byte-identical undo, an active session, a bad id), TestMoveCodexProviderPacked and TestCodexProviderIn fail without the change, as does sessions-codex-provider.test.cjs (WebKit, en and zh: magpie's dialog, Cancel posts nothing, an unticked one stays, Undo, no click moves the page, no left border, translations); sessions-manage, -toolbar, -purge, -folder-delete, -wsl-delete and click-scroll pass. Not tried against a real CC Switch history. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
1 parent
f55a13f23e
commit
585f7f7dfa
12 files changed
+1000
-3
No files matched your search
@@ -30,6 +30,8 @@ package agentenv
|
||||
var Vars = []string{
|
||||
// Claude Code, Codex and Copilot CLI
|
||||
"CLAUDE_CONFIG_DIR", "CODEX_HOME", "COPILOT_HOME",
|
||||
// Codex's state database, when it is kept apart from CODEX_HOME
|
||||
"CODEX_SQLITE_HOME",
|
||||
// Gemini CLI's session/config home
|
||||
"GEMINI_CLI_HOME",
|
||||
// Cline: its folder, its data, its sessions and its MCP settings file
|
||||
|
||||
@@ -1793,6 +1793,16 @@ const I18N = {
|
||||
"Select every session in {folder}": "选中 {folder} 下的全部会话",
|
||||
"Their files are moved to magpie's trash ({dir}), not erased: Trash puts them back. A session written to in the last minute is left alone, as {agent} may still be running it.": "会话文件移到 magpie 的回收站({dir}),不会抹除,可在「回收站」恢复。最近一分钟内有写入的会话不处理,{agent} 可能仍在运行它。",
|
||||
"Moved to magpie's trash": "已移到 magpie 的回收站",
|
||||
"1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there.": "有 1 个 Codex 会话是用其他供应商({from})创建的。Codex 现在的历史列表只显示 {to} 的会话,所以看不到它。",
|
||||
"{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.": "有 {n} 个 Codex 会话是用其他供应商({from})创建的。Codex 现在的历史列表只显示 {to} 的会话,所以看不到它们。",
|
||||
"Move to {to}…": "迁移到 {to}…",
|
||||
"Moved 1 session to {to}": "已把 1 个会话迁移到 {to}",
|
||||
"Moved {n} sessions to {to}": "已把 {n} 个会话迁移到 {to}",
|
||||
"Moved 1 session back": "已把 1 个会话迁移回去",
|
||||
"Moved {n} sessions back": "已把 {n} 个会话迁移回去",
|
||||
"Move sessions to {to}?": "把会话迁移到 {to}?",
|
||||
"Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.": "只改每个会话在文件和 Codex 数据库里记录的供应商,消息、会话 ID、标题和归档状态都不变。改之前会先在 magpie 的回收站文件夹里备份一份文件,「撤销」可以迁移回去。最近一分钟内还在写入的会话不会改动。",
|
||||
"Made with {from}: Codex's history lists only {to}'s sessions now": "用 {from} 创建:Codex 现在的历史列表只显示 {to} 的会话",
|
||||
"{n} sessions moved to magpie's trash": "{n} 个会话已移到 magpie 的回收站",
|
||||
"{title} is still being written to; close it in {agent} and try again in a minute": "「{title}」仍在写入;请在 {agent} 中关闭它,一分钟后再试",
|
||||
"{n} sessions are still being written to; close them in {agent} and try again in a minute": "{n} 个会话仍在写入;请在 {agent} 中关闭它们,一分钟后再试",
|
||||
@@ -5001,6 +5011,16 @@ const I18N = {
|
||||
"Select every session in {folder}": "{folder} のセッションをすべて選択",
|
||||
"Their files are moved to magpie's trash ({dir}), not erased: Trash puts them back. A session written to in the last minute is left alone, as {agent} may still be running it.": "ファイルは消去されず、magpie のゴミ箱({dir})に移動されます。「ゴミ箱」から元に戻せます。直近 1 分以内に書き込まれたセッションは、{agent} がまだ実行中の可能性があるため対象外です。",
|
||||
"Moved to magpie's trash": "magpie のゴミ箱に移動しました",
|
||||
"1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there.": "1 件の Codex セッションは別のプロバイダー({from})で作成されました。Codex の履歴には現在 {to} のセッションしか表示されないため、表示されません。",
|
||||
"{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.": "{n} 件の Codex セッションは別のプロバイダー({from})で作成されました。Codex の履歴には現在 {to} のセッションしか表示されないため、表示されません。",
|
||||
"Move to {to}…": "{to} に移行…",
|
||||
"Moved 1 session to {to}": "1 件のセッションを {to} に移行しました",
|
||||
"Moved {n} sessions to {to}": "{n} 件のセッションを {to} に移行しました",
|
||||
"Moved 1 session back": "1 件のセッションを元に戻しました",
|
||||
"Moved {n} sessions back": "{n} 件のセッションを元に戻しました",
|
||||
"Move sessions to {to}?": "セッションを {to} に移行しますか?",
|
||||
"Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.": "変更されるのは、ファイルと Codex のデータベースに記録されたプロバイダーだけです。メッセージ、ID、タイトル、アーカイブ状態はそのままです。変更前にファイルのコピーを magpie のゴミ箱フォルダーに保存し、「取り消す」で元に戻せます。直近 1 分以内に書き込まれたセッションは変更しません。",
|
||||
"Made with {from}: Codex's history lists only {to}'s sessions now": "{from} で作成:Codex の履歴には現在 {to} のセッションだけが表示されます",
|
||||
"{n} sessions moved to magpie's trash": "{n} 件のセッションを magpie のゴミ箱に移動しました",
|
||||
"{title} is still being written to; close it in {agent} and try again in a minute": "「{title}」はまだ書き込み中です。{agent} で閉じてから、1 分後にもう一度お試しください",
|
||||
"{n} sessions are still being written to; close them in {agent} and try again in a minute": "{n} 件のセッションはまだ書き込み中です。{agent} で閉じてから、1 分後にもう一度お試しください",
|
||||
@@ -8192,6 +8212,16 @@ const I18N = {
|
||||
"Select every session in {folder}": "Alle Sitzungen in {folder} auswählen",
|
||||
"Their files are moved to magpie's trash ({dir}), not erased: Trash puts them back. A session written to in the last minute is left alone, as {agent} may still be running it.": "Die Dateien werden in den Papierkorb von magpie ({dir}) verschoben, nicht gelöscht: Über „Papierkorb“ lassen sie sich wiederherstellen. Eine Sitzung, in die in der letzten Minute geschrieben wurde, bleibt unberührt, da {agent} sie eventuell noch ausführt.",
|
||||
"Moved to magpie's trash": "In den Papierkorb von magpie verschoben",
|
||||
"1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there.": "1 Codex-Sitzung wurde mit einem anderen Anbieter ({from}) erstellt: Codex zeigt in seinem Verlauf jetzt nur Sitzungen von {to}, daher erscheint sie dort nicht.",
|
||||
"{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.": "{n} Codex-Sitzungen wurden mit einem anderen Anbieter ({from}) erstellt: Codex zeigt in seinem Verlauf jetzt nur Sitzungen von {to}, daher erscheinen sie dort nicht.",
|
||||
"Move to {to}…": "Zu {to} umziehen…",
|
||||
"Moved 1 session to {to}": "1 Sitzung zu {to} umgezogen",
|
||||
"Moved {n} sessions to {to}": "{n} Sitzungen zu {to} umgezogen",
|
||||
"Moved 1 session back": "1 Sitzung zurückgezogen",
|
||||
"Moved {n} sessions back": "{n} Sitzungen zurückgezogen",
|
||||
"Move sessions to {to}?": "Sitzungen zu {to} umziehen?",
|
||||
"Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.": "Geändert wird nur der Anbieter, den jede Sitzung in ihren Dateien und in der Datenbank von Codex nennt: Nachrichten, ID, Titel und Archivstatus bleiben, wie sie sind. Vorher wird eine Kopie der Dateien im Papierkorbordner von magpie abgelegt, und Rückgängig zieht sie zurück. Eine Sitzung, in die in der letzten Minute geschrieben wurde, bleibt unverändert.",
|
||||
"Made with {from}: Codex's history lists only {to}'s sessions now": "Mit {from} erstellt: Der Verlauf von Codex zeigt jetzt nur Sitzungen von {to}",
|
||||
"{n} sessions moved to magpie's trash": "{n} Sitzungen in den Papierkorb von magpie verschoben",
|
||||
"{title} is still being written to; close it in {agent} and try again in a minute": "In „{title}“ wird noch geschrieben; schließen Sie die Sitzung in {agent} und versuchen Sie es in einer Minute erneut",
|
||||
"{n} sessions are still being written to; close them in {agent} and try again in a minute": "In {n} Sitzungen wird noch geschrieben; schließen Sie sie in {agent} und versuchen Sie es in einer Minute erneut",
|
||||
|
||||
@@ -61,3 +61,13 @@
|
||||
.sm-filter { width: 100%; min-width: 0; }
|
||||
.sm-trash-btn { grid-column: 2; justify-self: end; }
|
||||
}
|
||||
/* Codex sessions made with another provider, hidden from Codex's history (#887) */
|
||||
.row.sm-provider { gap: 10px; min-height: 40px; padding: 8px 11px; height: auto; }
|
||||
.sm-provider-text { min-width: 0; font-size: 12px; color: var(--fg-2); line-height: 1.45; }
|
||||
.sm-provider .grow { flex: 1; }
|
||||
.sm-provider .text { flex: none; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.sm-prov { display: inline-block; padding: 0 5px; border-radius: 4px; background: var(--pill); color: var(--muted); font-family: var(--code); font-size: 10.5px; line-height: 16px; }
|
||||
.sm-provider-list li label { display: flex; align-items: center; gap: 8px; min-width: 0; cursor: pointer; }
|
||||
.sm-provider-list li .name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sm-provider-list li .note { flex: none; font-size: 11px; color: var(--faint); }
|
||||
.sm-provider-list { max-height: min(300px, 45vh); overflow-y: auto; }
|
||||
@@ -216,11 +216,123 @@
|
||||
: "magpie can list {agent}'s sessions, but cannot resume or delete them.";
|
||||
box.append(el("p", "usage-note sm-note", t(key, { agent: a.name })));
|
||||
}
|
||||
const prov = providerBar();
|
||||
if (prov) box.append(prov);
|
||||
box.append(selectBar(), el("div", "sm-tree"));
|
||||
queueMicrotask(redrawList);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Codex lists in its history only the sessions made with the provider it
|
||||
// uses now; ones made with another (CC Switch's "custom") are there as
|
||||
// files but hidden from it (#887). They can be moved to Codex's provider
|
||||
// here, picked in a dialog first, and the last move undone.
|
||||
const otherProvider = (s) => !s.read_only && s.provider && s.uses_provider && s.provider !== s.uses_provider;
|
||||
let lastMove = null; // { agent, moved: [{ id, from, to }] }
|
||||
|
||||
function providerBar() {
|
||||
if (data.agent !== "codex") return null;
|
||||
const hidden = data.sessions.filter(otherProvider);
|
||||
const undo = lastMove?.agent === data.agent && lastMove.moved.length ? lastMove : null;
|
||||
if (!hidden.length && !undo) return null;
|
||||
const bar = el("div", "row sm-provider");
|
||||
if (hidden.length) {
|
||||
const from = [...new Set(hidden.map((s) => s.provider))].join(", ");
|
||||
const to = hidden[0].uses_provider;
|
||||
bar.append(el("span", "sm-provider-text", t(hidden.length === 1
|
||||
? "1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there."
|
||||
: "{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.", { n: hidden.length, from, to })));
|
||||
const go = el("button", "text sm-provider-move", t("Move to {to}…", { to }));
|
||||
go.type = "button";
|
||||
go.onclick = (e) => { e.stopPropagation(); askProvider(hidden); };
|
||||
bar.append(el("span", "grow"), go);
|
||||
} else {
|
||||
bar.append(el("span", "sm-provider-text", t(undo.moved.length === 1 ? "Moved 1 session to {to}" : "Moved {n} sessions to {to}", { n: undo.moved.length, to: undo.moved[0].to })), el("span", "grow"));
|
||||
}
|
||||
if (undo) {
|
||||
const back = el("button", "text sm-provider-undo");
|
||||
back.type = "button";
|
||||
back.append(svg(UNDO, 13, 1.4), el("span", "", t("Undo")));
|
||||
back.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
back.disabled = true;
|
||||
const out = await moveProviders(undo.moved.map((m) => ({ id: m.id, to: m.from })));
|
||||
if (!out) { back.disabled = false; return; }
|
||||
lastMove = null;
|
||||
if (!out.refused.length) status(t(out.moved.length === 1 ? "Moved 1 session back" : "Moved {n} sessions back", { n: out.moved.length }), "ok");
|
||||
await load();
|
||||
};
|
||||
bar.append(back);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
// moveProviders posts the moves, says what was refused, and gives the
|
||||
// reply (null when the post failed)
|
||||
async function moveProviders(moves) {
|
||||
let out;
|
||||
try {
|
||||
out = await api("sessions/codex-provider", { moves });
|
||||
} catch (err) {
|
||||
status(err.message, "err");
|
||||
return null;
|
||||
}
|
||||
if (out.refused.length) {
|
||||
const first = out.refused[0], s = data.sessions.find((x) => x.id === first.id);
|
||||
const active = out.refused.filter((r) => r.active);
|
||||
status(active.length ? t(active.length === 1 ? "{title} is still being written to; close it in {agent} and try again in a minute" : "{n} sessions are still being written to; close them in {agent} and try again in a minute", { title: s?.title || first.id, n: active.length, agent: "Codex" })
|
||||
: (s?.title || first.id) + ": " + first.error, "err");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// askProvider: the sessions to move, each ticked, to untick before Move
|
||||
function askProvider(list) {
|
||||
const to = list[0].uses_provider;
|
||||
const chosen = new Set(list.map((s) => s.id));
|
||||
const ed = el("div", "editor sm-ask sm-provider-ask");
|
||||
const h = el("div", "ehead");
|
||||
h.append(icon(current()?.icon || "generic"), el("b", "", t("Move sessions to {to}?", { to })));
|
||||
ed.append(h);
|
||||
const names = el("ul", "sm-ask-list sm-provider-list");
|
||||
const go = el("button", "text primary", t("Move"));
|
||||
for (const s of list) {
|
||||
const li = el("li");
|
||||
const lab = el("label");
|
||||
const c = el("input", "sm-check");
|
||||
c.type = "checkbox";
|
||||
c.checked = true;
|
||||
c.onchange = () => { c.checked ? chosen.add(s.id) : chosen.delete(s.id); go.disabled = !chosen.size; };
|
||||
lab.append(c, el("span", "name", s.title || s.id), el("span", "sm-prov", s.provider), el("span", "note", ago(s.last)));
|
||||
li.append(lab);
|
||||
names.append(li);
|
||||
}
|
||||
ed.append(names);
|
||||
ed.append(el("p", "lib-confirm", t("Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.")));
|
||||
const bar = el("div", "bar");
|
||||
go.type = "button";
|
||||
go.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
go.disabled = true;
|
||||
go.classList.add("busy");
|
||||
const out = await moveProviders([...chosen].map((id) => ({ id, to })));
|
||||
if (!out) { go.disabled = false; go.classList.remove("busy"); return; }
|
||||
closeConfirmAsk();
|
||||
lastMove = { agent: data.agent, moved: out.moved };
|
||||
if (!out.refused.length) status(t(out.moved.length === 1 ? "Moved 1 session to {to}" : "Moved {n} sessions to {to}", { n: out.moved.length, to }), "ok");
|
||||
await load();
|
||||
};
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
cancel.type = "button";
|
||||
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
||||
bar.append(el("span", "grow"), cancel, go);
|
||||
ed.append(bar);
|
||||
confirmAsk = ed;
|
||||
openModal(ed);
|
||||
$("#modal").classList.add("lib");
|
||||
cancel.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// the bar over the tree: pick every session shown, and delete those picked
|
||||
function selectBar() {
|
||||
const bar = el("div", "row-head sm-bar");
|
||||
@@ -344,6 +456,11 @@
|
||||
const sub = el("div", "sub", [ago(s.last), sessModelsText(s), s.messages ? t(s.messages === 1 ? "{n} message" : "{n} messages", { n: s.messages }) : "", fmtBytes(s.size), s.id.slice(0, 8)].filter(Boolean).join(" · "));
|
||||
if (s.via?.length) sub.title = s.via.map(viaText).join("\n");
|
||||
if (s.wsl) sub.prepend(wslBadge(s), " ");
|
||||
if (otherProvider(s)) {
|
||||
const p = el("span", "sm-prov", s.provider);
|
||||
p.title = t("Made with {from}: Codex's history lists only {to}'s sessions now", { from: s.provider, to: s.uses_provider });
|
||||
sub.prepend(p, " ");
|
||||
}
|
||||
who.append(sub);
|
||||
// what it spent, as the Usage page's list shows it
|
||||
r.append(who, ...sessSpent(s));
|
||||
|
||||
@@ -140,6 +140,41 @@ func sessionManageRoutes(mux *http.ServeMux, w Windows) {
|
||||
forgetStats()
|
||||
writeJSON(rw, out)
|
||||
})
|
||||
// codex-provider moves Codex sessions to another provider, each to its
|
||||
// own (the one Codex uses now, or the one it had, to undo): Codex's
|
||||
// history lists only the sessions of the provider it uses (#887).
|
||||
mux.HandleFunc("POST /api/sessions/codex-provider", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Moves []struct {
|
||||
ID string `json:"id"`
|
||||
To string `json:"to"`
|
||||
} `json:"moves"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
type refused struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
out := struct {
|
||||
Moved []sessions.CodexMove `json:"moved"`
|
||||
Refused []refused `json:"refused"`
|
||||
}{Moved: []sessions.CodexMove{}, Refused: []refused{}}
|
||||
for _, m := range in.Moves {
|
||||
mv, err := sessions.MoveCodexProvider(m.ID, m.To)
|
||||
if err != nil {
|
||||
out.Refused = append(out.Refused, refused{ID: m.ID, Error: err.Error(), Active: errors.Is(err, sessions.ErrActive)})
|
||||
continue
|
||||
}
|
||||
mv.Backup = tilde(mv.Backup)
|
||||
out.Moved = append(out.Moved, mv)
|
||||
}
|
||||
forgetStats()
|
||||
writeJSON(rw, out)
|
||||
})
|
||||
mux.HandleFunc("POST /api/sessions/restore", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -1069,6 +1069,15 @@ left and said so, other folders stay, and an agent magpie can't delete from
|
||||
has no boxes. No click moves the page, no left-border accent, every string
|
||||
in Chinese. Chromium and WebKit, English and Chinese, API faked.
|
||||
|
||||
`sessions-codex-provider.test.cjs` moves Codex sessions made with another
|
||||
provider to the one Codex uses now (#887): the Codex tab says how many there
|
||||
are and which provider, tags each, and Move opens magpie's own dialog with
|
||||
each ticked; Cancel posts nothing, an unticked one isn't moved, Move posts
|
||||
sessions/codex-provider with each id and Codex's provider, and Undo posts
|
||||
each back to the provider it had. No click moves the page, no left-border
|
||||
accent, every string in Chinese, Japanese and German. Chromium and WebKit,
|
||||
English and Chinese, API faked.
|
||||
|
||||
`sessions-toolbar.test.cjs` uses ten agents to check that fitting tabs stay
|
||||
visible at 1800px, while 900, 660 and 320px windows use a compact agent menu.
|
||||
Resizing keeps the current agent; search and Trash fit, the menu fits the
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Codex sessions made with another provider (CC Switch's "custom") are
|
||||
// hidden from Codex's history once Codex uses magpie (#887). The Sessions
|
||||
// page's Codex tab says how many and which provider, tags each one, and a
|
||||
// Move button opens magpie's own dialog listing them, each ticked: Cancel
|
||||
// sends nothing, an unticked one isn't moved, and Move posts
|
||||
// sessions/codex-provider with each id and Codex's provider. Undo then posts
|
||||
// each back to the provider it had. No click moves the page, nothing has a
|
||||
// left border. In English and Chinese, Chromium and WebKit; no backend, the
|
||||
// API is faked.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const at = (min) => new Date(Date.now() - min * 60e3).toISOString();
|
||||
const sess = (id, title, provider, min) => ({
|
||||
agent: "codex", id, title, cwd: "/work/app", start: at(min + 30), last: at(min), models: [], cost: 0, unpriced: 0,
|
||||
resume: "codex resume " + id, path: "~/.codex/sessions/x/" + id + ".jsonl", size: 2048, messages: 4, files: 1, deletable: true,
|
||||
provider, uses_provider: "magpie",
|
||||
});
|
||||
|
||||
function serve(lang, calls) {
|
||||
const store = { sessions: [sess("a1", "fix the login form", "custom", 5), sess("b2", "write the post", "custom", 10), sess("c3", "new work", "magpie", 1)] };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/sessions/manage") {
|
||||
return json({
|
||||
agents: [{ agent: "codex", count: store.sessions.length, deletable: true, name: "Codex", icon: "codex-color" }],
|
||||
agent: "codex", sessions: store.sessions, terminal: false, trash: [], trashDir: "~/Library/Application Support/magpie/trash/sessions",
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/sessions/codex-provider") {
|
||||
const body = route.request().postDataJSON();
|
||||
calls.push(body);
|
||||
const moved = body.moves.map((m) => {
|
||||
const s = store.sessions.find((x) => x.id === m.id);
|
||||
const from = s.provider;
|
||||
s.provider = m.to;
|
||||
return { id: m.id, from, to: m.to, files: [s.path], backup: "~/Library/Application Support/magpie/trash/codex-provider/x" };
|
||||
});
|
||||
return json({ moved, refused: [] });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: {
|
||||
nav: "Sessions", cancel: "Cancel", move: "Move", undo: "Undo",
|
||||
note: "2 Codex sessions were made with another provider (custom): Codex lists only magpie's sessions in its history now, so they don't show there.",
|
||||
button: "Move to magpie…", ask: "Move sessions to magpie?", moved: "Moved 1 session to magpie", back: "Moved 1 session back",
|
||||
},
|
||||
zh: {
|
||||
nav: "会话", cancel: "取消", move: "迁移", undo: "撤销",
|
||||
note: "有 2 个 Codex 会话是用其他供应商(custom)创建的。Codex 现在的历史列表只显示 magpie 的会话,所以看不到它们。",
|
||||
button: "迁移到 magpie…", ask: "把会话迁移到 magpie?", moved: "已把 1 个会话迁移到 magpie", back: "已把 1 个会话迁移回去",
|
||||
},
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: Codex sessions of another provider are moved to Codex's, after magpie's own dialog, and back`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 560 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("dialog", (d) => { errors.push("a browser dialog: " + d.message()); d.dismiss(); });
|
||||
const calls = [];
|
||||
await page.route("**/*", serve(lang, calls));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator("#nav").getByRole("button", { name: w.nav, exact: true }).click();
|
||||
const view = page.locator("#view-sessions");
|
||||
const bar = view.locator(".sm-provider");
|
||||
await bar.waitFor();
|
||||
assert.equal((await bar.locator(".sm-provider-text").textContent()).trim(), w.note);
|
||||
await view.locator(".row.sm-sess").first().waitFor();
|
||||
// the two of another provider are tagged with it, the other isn't
|
||||
assert.deepEqual(await view.locator(".row.sm-sess .sm-prov").allTextContents(), ["custom", "custom"]);
|
||||
const said = (text) => page.waitForFunction((x) => document.querySelector("#status")?.textContent === x, text)
|
||||
.catch(async () => assert.equal(await page.locator("#status").textContent(), text));
|
||||
const top = (l) => l.evaluate((e) => e.getBoundingClientRect().top);
|
||||
|
||||
const go = bar.getByRole("button", { name: w.button, exact: true });
|
||||
const before = await top(bar);
|
||||
await go.click();
|
||||
const ask = page.locator("#modal .sm-provider-ask");
|
||||
await ask.waitFor();
|
||||
assert.equal((await ask.locator(".ehead b").textContent()).trim(), w.ask);
|
||||
assert.deepEqual(await ask.locator(".sm-provider-list .name").allTextContents(), ["fix the login form", "write the post"]);
|
||||
const border = await page.evaluate(() => [...document.querySelectorAll("#view-sessions, #view-sessions *, #modal .sm-ask, #modal .sm-ask *")]
|
||||
.filter((e) => parseFloat(getComputedStyle(e).borderLeftWidth) > 1 && getComputedStyle(e).borderLeftColor !== getComputedStyle(e).borderRightColor).map((e) => e.className));
|
||||
assert.deepEqual(border, [], "no left-border accent");
|
||||
await ask.getByRole("button", { name: w.cancel, exact: true }).click();
|
||||
await ask.waitFor({ state: "detached" });
|
||||
assert.equal(calls.length, 0, "Cancel moves nothing");
|
||||
assert.equal(await top(bar), before, "the click moved the page");
|
||||
|
||||
// one unticked stays; Move posts the other, to Codex's provider
|
||||
await go.click();
|
||||
await ask.waitFor();
|
||||
await ask.locator(".sm-provider-list li", { hasText: "write the post" }).locator("input").uncheck();
|
||||
await ask.getByRole("button", { name: w.move, exact: true }).click();
|
||||
await ask.waitFor({ state: "detached" });
|
||||
assert.deepEqual(calls[0], { moves: [{ id: "a1", to: "magpie" }] });
|
||||
await said(w.moved);
|
||||
await page.waitForFunction(() => document.querySelectorAll("#view-sessions .row.sm-sess .sm-prov").length === 1);
|
||||
|
||||
// Undo moves it back to the provider it had
|
||||
const undo = view.locator(".sm-provider-undo");
|
||||
await undo.click();
|
||||
await said(w.back);
|
||||
assert.deepEqual(calls[1], { moves: [{ id: "a1", to: "custom" }] });
|
||||
await page.waitForFunction(() => document.querySelectorAll("#view-sessions .row.sm-sess .sm-prov").length === 2);
|
||||
assert.equal(await view.locator(".sm-provider-undo").count(), 0, "nothing left to undo");
|
||||
|
||||
const missing = await page.evaluate(() => [
|
||||
"1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there.",
|
||||
"{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.",
|
||||
"Move to {to}…", "Moved 1 session to {to}", "Moved {n} sessions to {to}", "Moved 1 session back", "Moved {n} sessions back",
|
||||
"Move sessions to {to}?", "Made with {from}: Codex's history lists only {to}'s sessions now",
|
||||
"Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.",
|
||||
].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
|
||||
assert.deepEqual(missing, [], "every string has its translations");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,11 @@ type cxLine struct {
|
||||
ID string `json:"id"`
|
||||
Cwd string `json:"cwd"`
|
||||
Model string `json:"model"`
|
||||
Role string `json:"role"`
|
||||
Message string `json:"message"`
|
||||
Content []struct {
|
||||
// session_meta's
|
||||
ModelProvider string `json:"model_provider"`
|
||||
Role string `json:"role"`
|
||||
Message string `json:"message"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
@@ -220,6 +222,9 @@ func codexBody(s *state, b []byte, main bool) {
|
||||
codexState(s).beginSnapshot(at, p.ID, p.cxHistorySnapshot)
|
||||
if s.ID == "" {
|
||||
s.ID = p.ID
|
||||
if s.Provider = p.ModelProvider; s.Provider == "" {
|
||||
s.Provider = "openai"
|
||||
}
|
||||
}
|
||||
if s.Cwd == "" {
|
||||
s.Cwd = p.Cwd
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// Codex lists, in its history and its resume picker, only the sessions made
|
||||
// with the provider it uses now: a rollout's session_meta names its
|
||||
// model_provider, and so does the thread's row in Codex's state database
|
||||
// (state_<n>.sqlite). Sessions made through CC Switch ("custom") are gone
|
||||
// from that list once Codex uses magpie, though their files are all there
|
||||
// (#887). MoveCodexProvider changes a session's provider, in its files and
|
||||
// in that row, and nothing else: its messages, id, title and archive stay as
|
||||
// they were. A copy of each file as it was is kept in magpie's trash folder
|
||||
// first, and moving it back to the provider it had is the undo.
|
||||
|
||||
// codexProviderRe is what a provider id may be: a key of config.toml's
|
||||
// [model_providers], or a built-in one.
|
||||
var codexProviderRe = regexp.MustCompile(`^[0-9A-Za-z_.-]{1,64}$`)
|
||||
|
||||
// CodexMove is one session moved to another provider.
|
||||
type CodexMove struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Files []string `json:"files"`
|
||||
Backup string `json:"backup"` // its files as they were
|
||||
DB []string `json:"db,omitempty"`
|
||||
}
|
||||
|
||||
// codexMetaProvider is the model_provider the first session_meta of a
|
||||
// rollout names, "openai" (Codex's default) when it names none, and "" when
|
||||
// the file has no session_meta in its first lines.
|
||||
func codexMetaProvider(path string) string {
|
||||
r, err := openLines(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer r.Close()
|
||||
br := bufio.NewReaderSize(r, 64<<10)
|
||||
for i := 0; i < 8; i++ {
|
||||
line, err := br.ReadBytes('\n')
|
||||
if gjson.GetBytes(line, "type").String() == "session_meta" {
|
||||
if p := gjson.GetBytes(line, "payload.model_provider").String(); p != "" {
|
||||
return p
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// codexHomeOf is the Codex folder a rollout is kept in: the one holding its
|
||||
// sessions or archived_sessions folder.
|
||||
func codexHomeOf(path string) string {
|
||||
for d := filepath.Dir(path); ; {
|
||||
up := filepath.Dir(d)
|
||||
if up == d {
|
||||
return ""
|
||||
}
|
||||
if b := filepath.Base(d); b == "sessions" || b == "archived_sessions" {
|
||||
return up
|
||||
}
|
||||
d = up
|
||||
}
|
||||
}
|
||||
|
||||
// codexConfig is what of a Codex folder's config.toml says where sessions
|
||||
// go: the provider in use (a profile's when one is chosen), and where its
|
||||
// databases are.
|
||||
type codexConfig struct {
|
||||
Provider string `toml:"model_provider"`
|
||||
Profile string `toml:"profile"`
|
||||
SQLiteHome string `toml:"sqlite_home"`
|
||||
Profiles map[string]struct {
|
||||
Provider string `toml:"model_provider"`
|
||||
} `toml:"profiles"`
|
||||
}
|
||||
|
||||
func readCodexConfig(home string) codexConfig {
|
||||
var c codexConfig
|
||||
if b, err := os.ReadFile(filepath.Join(home, "config.toml")); err == nil {
|
||||
toml.Unmarshal(b, &c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// CodexProviderIn is the provider the Codex of a folder uses now, whose
|
||||
// sessions its history lists: config.toml's model_provider, its profile's
|
||||
// when it chooses one, else "openai".
|
||||
func CodexProviderIn(home string) string {
|
||||
c := readCodexConfig(home)
|
||||
if p, ok := c.Profiles[c.Profile]; ok && c.Profile != "" && p.Provider != "" {
|
||||
return p.Provider
|
||||
}
|
||||
if c.Provider != "" {
|
||||
return c.Provider
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// codexStateDBs are the state databases of a Codex folder: state_<n>.sqlite,
|
||||
// in sqlite_home when config.toml (or CODEX_SQLITE_HOME) names one.
|
||||
func codexStateDBs(home string) []string {
|
||||
dir := home
|
||||
if c := readCodexConfig(home); c.SQLiteHome != "" && home == CodexDir() {
|
||||
dir = c.SQLiteHome
|
||||
}
|
||||
if d := os.Getenv("CODEX_SQLITE_HOME"); d != "" && home == CodexDir() {
|
||||
dir = d
|
||||
}
|
||||
out, _ := filepath.Glob(filepath.Join(dir, "state_*.sqlite"))
|
||||
return out
|
||||
}
|
||||
|
||||
// codexProviders fills in each Codex session's provider, and the one its
|
||||
// Codex uses now: read from the file once, then kept in the cache.
|
||||
func codexProviders(out []Managed, groups map[string][]file) {
|
||||
homes := map[string]string{}
|
||||
filled := false
|
||||
for i := range out {
|
||||
m := &out[i]
|
||||
if m.Agent != "codex" || m.ReadOnly {
|
||||
continue
|
||||
}
|
||||
fs := groups["codex:"+m.ID]
|
||||
var main *file
|
||||
for j := range fs {
|
||||
if fs[j].main && (main == nil || fs[j].path == m.Path) {
|
||||
main = &fs[j]
|
||||
}
|
||||
}
|
||||
if main == nil {
|
||||
continue
|
||||
}
|
||||
st := cache[main.path]
|
||||
if st != nil && st.Provider == "" {
|
||||
st.Provider = codexMetaProvider(main.path)
|
||||
filled = filled || st.Provider != ""
|
||||
}
|
||||
if st != nil {
|
||||
m.Provider = st.Provider
|
||||
} else {
|
||||
m.Provider = codexMetaProvider(main.path)
|
||||
}
|
||||
home := codexHomeOf(main.path)
|
||||
if _, ok := homes[home]; !ok {
|
||||
homes[home] = CodexProviderIn(home)
|
||||
}
|
||||
m.UsesProvider = homes[home]
|
||||
}
|
||||
if filled {
|
||||
saveCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCodexProvider moves a Codex session to the provider to: the
|
||||
// model_provider of each session_meta in its files, and of its row in
|
||||
// Codex's state database. A session written to in the last minute is left
|
||||
// as it is (ErrActive), as Codex may be running it. Each file is copied to
|
||||
// <magpie dir>/trash/codex-provider/ before it is changed, and rewritten
|
||||
// whole into a file beside it that then takes its place, keeping its time.
|
||||
func MoveCodexProvider(id, to string) (CodexMove, error) {
|
||||
if !safeID.MatchString(id) {
|
||||
return CodexMove{}, errors.New("no such session")
|
||||
}
|
||||
if !codexProviderRe.MatchString(to) {
|
||||
return CodexMove{}, fmt.Errorf("%q isn't a provider id", to)
|
||||
}
|
||||
dbReadMu.Lock()
|
||||
defer dbReadMu.Unlock()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
loadCache()
|
||||
defer closeDBs()
|
||||
all := allFiles()
|
||||
groups := map[string][]file{}
|
||||
var mine []file
|
||||
for _, f := range all {
|
||||
if f.agent == "codex" {
|
||||
groups[f.key] = append(groups[f.key], f)
|
||||
mine = append(mine, f)
|
||||
}
|
||||
}
|
||||
refresh(mine, all)
|
||||
var fs []file
|
||||
price := pricer()
|
||||
for _, g := range groups {
|
||||
if one, _ := assemble(g, price); one.ID == id && !one.ReadOnly {
|
||||
fs = g
|
||||
break
|
||||
}
|
||||
}
|
||||
var paths []string
|
||||
for _, f := range fs {
|
||||
for _, p := range []string{f.path, rolloutTwin(f.path)} {
|
||||
if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() && !contains(paths, p) {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return CodexMove{}, errors.New("no such session")
|
||||
}
|
||||
now := time.Now()
|
||||
for _, p := range paths {
|
||||
if recent(p, now) {
|
||||
return CodexMove{}, ErrActive
|
||||
}
|
||||
}
|
||||
mv := CodexMove{ID: id, To: to}
|
||||
for _, p := range paths {
|
||||
if mv.From = codexMetaProvider(p); mv.From != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
// the files as they were, before any is changed
|
||||
name := now.UTC().Format("20060102T150405.000000000") + "-" + id
|
||||
dir := filepath.Join(settings.Dir(), "trash", "codex-provider", name)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o700); err != nil {
|
||||
return CodexMove{}, err
|
||||
}
|
||||
type item struct {
|
||||
From string `json:"from"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var items []item
|
||||
for i, p := range paths {
|
||||
it := item{From: p, Name: strconv.Itoa(i) + "-" + filepath.Base(p)}
|
||||
if err := keptCopy(p, filepath.Join(dir, "files", it.Name)); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return CodexMove{}, err
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
note, _ := json.MarshalIndent(struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
At time.Time `json:"at"`
|
||||
Items []item `json:"items"`
|
||||
}{id, mv.From, to, now, items}, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(dir, "move.json"), note, 0o600); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return CodexMove{}, err
|
||||
}
|
||||
mv.Backup = dir
|
||||
for _, p := range paths {
|
||||
changed, err := rewriteCodexProvider(p, to)
|
||||
if err != nil {
|
||||
// the files changed so far go back as they were
|
||||
for _, it := range items {
|
||||
if contains(mv.Files, it.From) {
|
||||
keptCopy(filepath.Join(dir, "files", it.Name), it.From)
|
||||
}
|
||||
}
|
||||
return CodexMove{}, err
|
||||
}
|
||||
if changed {
|
||||
mv.Files = append(mv.Files, p)
|
||||
}
|
||||
}
|
||||
for _, f := range fs {
|
||||
delete(cache, f.path)
|
||||
}
|
||||
saveCache()
|
||||
homes := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
homes[codexHomeOf(p)] = true
|
||||
}
|
||||
for home := range homes {
|
||||
for _, db := range codexStateDBs(home) {
|
||||
if ok, err := moveCodexThread(db, id, to); err != nil {
|
||||
return mv, fmt.Errorf("the files moved, but Codex's %s didn't: %w", filepath.Base(db), err)
|
||||
} else if ok {
|
||||
mv.DB = append(mv.DB, db)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mv, nil
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keptCopy copies a file, keeping its mode and time.
|
||||
func keptCopy(from, to string) error {
|
||||
fi, err := os.Stat(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fi.Mode().Perm()|0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(to, fi.ModTime(), fi.ModTime())
|
||||
}
|
||||
|
||||
// setMetaProvider is a line with its session_meta's model_provider set to
|
||||
// to, every other byte as it was; any other line as it is. A session_meta
|
||||
// that names no provider (a subagent's segment, an old Codex's) is left as
|
||||
// it is too, so moving back gives the file it was.
|
||||
func setMetaProvider(line []byte, to string) ([]byte, bool) {
|
||||
if !bytes.Contains(line[:min(len(line), 1024)], cxMeta) || gjson.GetBytes(line, "type").String() != "session_meta" {
|
||||
return line, false
|
||||
}
|
||||
r := gjson.GetBytes(line, "payload.model_provider")
|
||||
if r.Type != gjson.String || r.Index <= 0 || r.Str == to {
|
||||
return line, false
|
||||
}
|
||||
val, _ := json.Marshal(to)
|
||||
out := append([]byte{}, line[:r.Index]...)
|
||||
out = append(out, val...)
|
||||
return append(out, line[r.Index+len(r.Raw):]...), true
|
||||
}
|
||||
|
||||
// rewriteCodexProvider sets the provider of every session_meta in a rollout,
|
||||
// compressed or not, and says whether anything changed. The file is written
|
||||
// beside it and renamed over it, with the time it had.
|
||||
func rewriteCodexProvider(path, to string) (bool, error) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
in, err := openLines(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".magpie-"+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
}()
|
||||
var w io.Writer = tmp
|
||||
var z *zstd.Encoder
|
||||
if packed(path) {
|
||||
if z, err = zstd.NewWriter(tmp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
w = z
|
||||
}
|
||||
bw := bufio.NewWriterSize(w, 64<<10)
|
||||
br := bufio.NewReaderSize(in, 64<<10)
|
||||
changed := false
|
||||
for {
|
||||
line, err := br.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
out, ok := setMetaProvider(line, to)
|
||||
changed = changed || ok
|
||||
if _, werr := bw.Write(out); werr != nil {
|
||||
return false, werr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
if err := bw.Flush(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if z != nil {
|
||||
if err := z.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err := tmp.Chmod(fi.Mode().Perm()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
in.Close()
|
||||
os.Chtimes(tmp.Name(), fi.ModTime(), fi.ModTime())
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
done = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// moveCodexThread sets a thread's model_provider in a Codex state
|
||||
// database, and says whether it had a row there. A database without the
|
||||
// threads table (another version of Codex) is left alone.
|
||||
func moveCodexThread(path, id, to string) (bool, error) {
|
||||
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path), RawQuery: "_pragma=busy_timeout(5000)"}
|
||||
if len(u.Path) >= 2 && u.Path[1] == ':' {
|
||||
u.Path = "/" + u.Path
|
||||
}
|
||||
db, err := sql.Open("sqlite", u.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer db.Close()
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('threads') WHERE name IN ('id','model_provider')`).Scan(&n); err != nil || n != 2 {
|
||||
return false, nil
|
||||
}
|
||||
res, err := db.Exec(`UPDATE threads SET model_provider = ? WHERE id = ?`, to, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// codexState makes a Codex state database with a thread's row, as Codex
|
||||
// 0.159's state_5.sqlite has it (the columns that matter).
|
||||
func codexStateDB(t *testing.T, home, id, provider string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(home, "state_5.sqlite")
|
||||
db, err := sql.Open("sqlite", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, title TEXT NOT NULL, archived INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE INDEX idx_threads_provider ON threads(model_provider);
|
||||
INSERT INTO threads VALUES (?, 'x', ?, 'the title', 1), ('other', 'y', ?, 'another', 0)`, id, provider, provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func threadProvider(t *testing.T, db, id string) (provider, title string, archived int) {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite", db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.QueryRow(`SELECT model_provider, title, archived FROM threads WHERE id = ?`, id).Scan(&provider, &title, &archived); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A Codex session made with another provider (CC Switch's "custom") is
|
||||
// hidden from Codex's history once Codex uses magpie (#887). The Sessions
|
||||
// page says which provider each was made with and which Codex uses now;
|
||||
// MoveCodexProvider changes the provider in its files and in Codex's state
|
||||
// database and nothing else, keeps a copy of the files first, and moving
|
||||
// it back gives the files it had, byte for byte.
|
||||
func TestMoveCodexProvider(t *testing.T) {
|
||||
_, codex := manageSetup(t)
|
||||
main := filepath.Join(codex, "sessions", "2026", "09", "20", "rollout-2026-09-20T15-48-28-"+mgCX+".jsonl")
|
||||
b, err := os.ReadFile(main)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := bytes.Replace(b, []byte(`"model_provider":"openai"`), []byte(`"model_provider":"custom"`), 1)
|
||||
old := time.Now().Add(-24 * time.Hour).Truncate(time.Second)
|
||||
os.WriteFile(main, orig, 0o644)
|
||||
os.Chtimes(main, old, old)
|
||||
seg := filepath.Join(codex, "sessions", "2026", "09", "23", "rollout-2026-09-23T00-27-05-"+mgCX+"_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl")
|
||||
segOrig, _ := os.ReadFile(seg)
|
||||
os.WriteFile(filepath.Join(codex, "config.toml"), []byte("model = \"gpt-6\"\nmodel_provider = \"magpie\"\n\n[model_providers.magpie]\nname = \"magpie\"\n"), 0o644)
|
||||
db := codexStateDB(t, codex, mgCX, "custom")
|
||||
|
||||
m, ok := findManaged(ListAgent("codex"), mgCX)
|
||||
if !ok || m.Provider != "custom" || m.UsesProvider != "magpie" {
|
||||
t.Fatalf("listed %q, uses %q; want custom, magpie", m.Provider, m.UsesProvider)
|
||||
}
|
||||
|
||||
mv, err := MoveCodexProvider(mgCX, "magpie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mv.From != "custom" || mv.To != "magpie" || len(mv.Files) != 1 || mv.Files[0] != main || len(mv.DB) != 1 {
|
||||
t.Fatalf("moved %+v", mv)
|
||||
}
|
||||
now, _ := os.ReadFile(main)
|
||||
if want := bytes.Replace(orig, []byte(`"model_provider":"custom"`), []byte(`"model_provider":"magpie"`), 1); !bytes.Equal(now, want) {
|
||||
t.Fatalf("the file became\n%s", now)
|
||||
}
|
||||
if fi, _ := os.Stat(main); !fi.ModTime().Equal(old) {
|
||||
t.Errorf("its time became %v, want %v", fi.ModTime(), old)
|
||||
}
|
||||
if b, _ := os.ReadFile(seg); !bytes.Equal(b, segOrig) {
|
||||
t.Error("a segment naming no provider was changed")
|
||||
}
|
||||
if p, title, arch := threadProvider(t, db, mgCX); p != "magpie" || title != "the title" || arch != 1 {
|
||||
t.Errorf("thread row %q %q %d", p, title, arch)
|
||||
}
|
||||
if p, _, _ := threadProvider(t, db, "other"); p != "custom" {
|
||||
t.Errorf("another thread became %q", p)
|
||||
}
|
||||
if kept, _ := filepath.Glob(filepath.Join(mv.Backup, "files", "*")); len(kept) != 2 {
|
||||
t.Fatalf("backup %v", kept)
|
||||
} else if b, _ := os.ReadFile(filepath.Join(mv.Backup, "files", "0-"+filepath.Base(main))); !bytes.Equal(b, orig) {
|
||||
t.Error("the backup isn't the file as it was")
|
||||
}
|
||||
if !strings.HasPrefix(mv.Backup, os.Getenv("XDG_CONFIG_HOME")) || !strings.Contains(mv.Backup, filepath.Join("trash", "codex-provider")) {
|
||||
t.Errorf("backup in %s", mv.Backup)
|
||||
}
|
||||
if m, _ := findManaged(ListAgent("codex"), mgCX); m.Provider != "magpie" {
|
||||
t.Fatalf("listed as %q after the move", m.Provider)
|
||||
}
|
||||
|
||||
// undo: back to custom, the file as it was
|
||||
if _, err := MoveCodexProvider(mgCX, "custom"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(main); !bytes.Equal(b, orig) {
|
||||
t.Fatal("moving back didn't give the file it was")
|
||||
}
|
||||
if p, _, _ := threadProvider(t, db, mgCX); p != "custom" {
|
||||
t.Errorf("thread row %q after moving back", p)
|
||||
}
|
||||
|
||||
// one being written is left alone
|
||||
hot := time.Now()
|
||||
os.Chtimes(main, hot, hot)
|
||||
if _, err := MoveCodexProvider(mgCX, "magpie"); !errors.Is(err, ErrActive) {
|
||||
t.Fatalf("err %v, want ErrActive", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(main); !bytes.Equal(b, orig) {
|
||||
t.Fatal("a running session was changed")
|
||||
}
|
||||
if _, err := MoveCodexProvider(mgCX, "bad id"); err == nil {
|
||||
t.Fatal("a provider id with a space was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// A rollout the Codex app compressed (.jsonl.zst) is moved too, and stays
|
||||
// compressed.
|
||||
func TestMoveCodexProviderPacked(t *testing.T) {
|
||||
_, codex := manageSetup(t)
|
||||
main := filepath.Join(codex, "sessions", "2026", "09", "20", "rollout-2026-09-20T15-48-28-"+mgCX+".jsonl")
|
||||
b, _ := os.ReadFile(main)
|
||||
b = bytes.Replace(b, []byte(`"model_provider":"openai"`), []byte(`"model_provider":"custom"`), 1)
|
||||
enc, _ := zstd.NewWriter(nil)
|
||||
os.WriteFile(main+zstSuffix, enc.EncodeAll(b, nil), 0o644)
|
||||
os.Remove(main)
|
||||
old := time.Now().Add(-24 * time.Hour)
|
||||
os.Chtimes(main+zstSuffix, old, old)
|
||||
|
||||
if m, _ := findManaged(ListAgent("codex"), mgCX); m.Provider != "custom" || m.UsesProvider != "openai" {
|
||||
t.Fatalf("listed %q, uses %q", m.Provider, m.UsesProvider)
|
||||
}
|
||||
mv, err := MoveCodexProvider(mgCX, "openai")
|
||||
if err != nil || len(mv.Files) != 1 {
|
||||
t.Fatalf("%+v %v", mv, err)
|
||||
}
|
||||
f, err := os.Open(main + zstSuffix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
dec, _ := zstd.NewReader(f)
|
||||
defer dec.Close()
|
||||
var got bytes.Buffer
|
||||
if _, err := got.ReadFrom(dec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := bytes.Replace(b, []byte(`"model_provider":"custom"`), []byte(`"model_provider":"openai"`), 1); !bytes.Equal(got.Bytes(), want) {
|
||||
t.Fatal("the compressed file wasn't moved")
|
||||
}
|
||||
}
|
||||
|
||||
// Codex's provider is its profile's when config.toml chooses one.
|
||||
func TestCodexProviderIn(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
if p := CodexProviderIn(d); p != "openai" {
|
||||
t.Fatalf("no config: %q", p)
|
||||
}
|
||||
os.WriteFile(filepath.Join(d, "config.toml"), []byte("model_provider = \"custom\"\nprofile = \"m\"\n[profiles.m]\nmodel_provider = \"magpie\"\n"), 0o644)
|
||||
if p := CodexProviderIn(d); p != "magpie" {
|
||||
t.Fatalf("profile: %q", p)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ type Managed struct {
|
||||
Messages int `json:"messages"`
|
||||
Files int `json:"files"`
|
||||
Deletable bool `json:"deletable"`
|
||||
// Codex: the provider the session was made with, and the one its Codex
|
||||
// uses now, whose sessions alone its history lists (#887)
|
||||
Provider string `json:"provider,omitempty"`
|
||||
UsesProvider string `json:"uses_provider,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCount is an agent with sessions on this computer, and how many.
|
||||
@@ -134,6 +138,9 @@ func ListAgent(agent string) []Managed {
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if agent == "codex" {
|
||||
codexProviders(out, groups)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if !out[i].Last.Equal(out[j].Last) {
|
||||
return out[i].Last.After(out[j].Last)
|
||||
|
||||
@@ -132,6 +132,8 @@ type state struct {
|
||||
// Codex: the model in use, and its running total (input with cache) last seen
|
||||
Model string `json:"model,omitempty"`
|
||||
Total *Tokens `json:"total,omitempty"`
|
||||
// Codex: the model_provider its session_meta names (codex_provider.go)
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Pi: in a forked session, the time it was forked; the lines before
|
||||
// it are the copy of the session it was forked from
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
|
||||
Reference in new issue
Block a user