diff --git a/README.md b/README.md index 0e9796a..979dc9a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Setup: 3 commands - 9 agents + 10 agents Native resume

@@ -31,6 +31,7 @@ I use Claude Code for the heavy reasoning and cheaper agents for the easy stuff. | Claude Code | JSONL files under `~/.claude/projects` | | OpenCode | a SQLite database, `opencode.db` | | Codex CLI | rollout JSONL files under `~/.codex/sessions` | +| Hermes Agent | a SQLite `state.db` under `$HERMES_HOME` (default `~/.hermes`) | | Gemini CLI | JSONL files under `~/.gemini/tmp`, keyed by a project registry | | Qwen Code | JSONL files under `~/.qwen/projects` | | Pi | a JSONL entry tree under `~/.pi/agent/sessions` | @@ -75,9 +76,10 @@ That's it. Keep working the way you do, and use each agent's own resume. `hop ag | **Kimi Code** 2.x | ✅ | ✅ event log + custom title | `[[hooks]]` `Stop` in `config.toml` | resume ✔ · hook ✔ | | **Gemini CLI** | ✅ | ✅ in folders Gemini already knows | `AfterAgent` + `SessionEnd` hooks | listed with title ✔ | | **Antigravity CLI** (`agy`) | ✅ | ➖ no import API: `hop resume agy` seeds a new conversation | `Stop` hook | read ✔ · hook ✔ | +| **Hermes Agent** | ✅ | ✅ SQLite `sessions`/`messages` + `hermes --resume` | none yet: picked up by any other agent's sync | resume ✔ | | **MiniMax Code** | ✅ | ➖ `hop resume mmx` seeds a new session | none yet: picked up by any other agent's sync | read ✔ | -"resume ✔" means a mirror written by SameThread was reopened with the agent's own resume command, and the model answered a question only the imported history could answer. Tested on Windows 11 with Claude Code 2.1.280, OpenCode 1.18.32, Codex 0.156.1, Qwen Code 0.24.4, Pi 0.87.1, Kimi Code 2.0.2, Gemini CLI 0.62 nightly, agy 1.2.9 and MiniMax Code 0.5.2. Storage paths are the same on macOS and Linux, and CI runs the tests on all three systems. +"resume ✔" means a mirror written by SameThread was reopened with the agent's own resume command, and the model answered a question only the imported history could answer. Tested on Windows 11 with Claude Code 2.1.280, OpenCode 1.18.32, Codex 0.156.1, Qwen Code 0.24.4, Pi 0.87.1, Kimi Code 2.0.2, Gemini CLI 0.62 nightly, agy 1.2.9, MiniMax Code 0.5.2 and Hermes Agent (on macOS; a mirror was resumed in Hermes and it answered from the imported history). Storage paths are the same on macOS and Linux, and CI runs the tests on all three systems. ## How it works @@ -141,7 +143,7 @@ Logs go to `~/.hop/hop.log`. | Writes into current OpenCode (1.x) and Kimi Code | ✅ | ❌ | ➖ | | Automatic sync through hooks | ✅ | ❌ one-shot | ❌ one-shot | | Round-trips with source tags | ✅ | ❌ | ❌ | -| Number of agents | 9 | 15+ | 6 | +| Number of agents | 10 | 15+ | 6 | casr covers more agents. Reach for it when you need a one-off conversion to something like Cursor or Aider. SameThread is for people who switch between agents all day and want it to be invisible. diff --git a/pyproject.toml b/pyproject.toml index b988dcb..6560156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta" [project] name = "samethread" dynamic = ["version"] -description = "Hop between coding agents, stay on the same thread: one chat history across Claude Code, OpenCode, Codex, Gemini CLI, Qwen Code, Pi, Kimi Code, Antigravity and MiniMax Code." +description = "Hop between coding agents, stay on the same thread: one chat history across Claude Code, OpenCode, Codex, Gemini CLI, Qwen Code, Pi, Kimi Code, Antigravity, MiniMax Code and Hermes Agent." readme = "README.md" requires-python = ">=3.9" license = { text = "MIT" } authors = [{ name = "GodrezJr2" }] -keywords = ["claude-code", "opencode", "codex", "gemini-cli", "qwen-code", "pi", "kimi", "antigravity", "minimax", "ai-agents", "coding-agents", "session", "resume", "cli"] +keywords = ["claude-code", "opencode", "codex", "gemini-cli", "qwen-code", "pi", "kimi", "antigravity", "minimax", "hermes", "ai-agents", "coding-agents", "session", "resume", "cli"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", diff --git a/src/samethread/agents/__init__.py b/src/samethread/agents/__init__.py index 54e1ab3..57bdd9f 100644 --- a/src/samethread/agents/__init__.py +++ b/src/samethread/agents/__init__.py @@ -3,13 +3,15 @@ from .claude import Claude from .codex import Codex from .gemini import Gemini +from .hermes import Hermes from .kimi import Kimi from .minimax import MiniMax from .opencode import OpenCode from .pi import Pi from .qwen import Qwen -AGENTS = {a.key: a for a in (Claude(), OpenCode(), Antigravity(), Codex(), Gemini(), Qwen(), Pi(), Kimi(), MiniMax())} +AGENTS = {a.key: a for a in (Claude(), OpenCode(), Hermes(), Antigravity(), Codex(), Gemini(), Qwen(), Pi(), Kimi(), + MiniMax())} NAMES = {k: a.name for k, a in AGENTS.items()} diff --git a/src/samethread/agents/hermes.py b/src/samethread/agents/hermes.py new file mode 100644 index 0000000..e41bdf4 --- /dev/null +++ b/src/samethread/agents/hermes.py @@ -0,0 +1,189 @@ +"""Hermes Agent: $HERMES_HOME/state.db — SQLite `sessions`/`messages` tables.""" +import datetime as dt +import json +import os +import sqlite3 +import uuid + +from ..core import HOME, HOP_ORIGIN, HopError, el, fmt_call, fmt_result +from .base import Agent + +ROOT = os.environ.get('HERMES_HOME') or os.path.join(HOME, '.hermes') +DB = os.path.join(ROOT, 'state.db') +ORIGIN = json.dumps({'imported_from': {'tool': HOP_ORIGIN}}) +FIELDS = ('id, source, model, system_prompt, parent_session_id, title, cwd, profile_name, ' + 'started_at, last_activity_at, message_count, origin_json') +KEYS = tuple(f.strip() for f in FIELDS.split(',')) + + +def connect(rw=False): + if not os.path.exists(DB): + return None # no store yet (Hermes never run) or removed; callers treat None as "nothing to do" + if rw: + return sqlite3.connect(f'file:{DB}?mode=rw', uri=True, timeout=15) + try: + c = sqlite3.connect(f'file:{DB}?mode=ro', uri=True, timeout=15) + c.execute('pragma schema_version') # sqlite3.connect is lazy: force the open so a failure is catchable + return c + except sqlite3.OperationalError: + # A WAL-mode db whose -wal/-shm sidecars are gone (Hermes exited cleanly) cannot be opened + # mode=ro; a read-write handle reads the same committed data. + return sqlite3.connect(f'file:{DB}?mode=rw', uri=True, timeout=15) + + +def profile_name(): + """/state.db -> 'default'; /profiles//state.db -> ''.""" + parent = os.path.dirname(DB) + return os.path.basename(parent) if os.path.basename(os.path.dirname(parent)) == 'profiles' else 'default' + + +def new_session_id(): + return dt.datetime.now().strftime('%Y%m%d_%H%M%S_') + uuid.uuid4().hex[:6] + + +def stat(c, sid): + """Post-write signature of one mirror: what sync stores to detect later changes.""" + n, mx = c.execute('select count(*), coalesce(max(id), 0) from messages where session_id=?', (sid,)).fetchone() + row = c.execute('select last_activity_at, started_at from sessions where id=?', (sid,)).fetchone() + upd = int(max(row[0] or 0, row[1] or 0) * 1000) if row else 0 + return {'sig': f'{upd}:{n}:{mx}', 'updated': upd} + + +class Hermes(Agent): + key, name, aliases, binary, writable = 'hermes', 'Hermes', ('hermes-agent',), 'hermes', True + + def detect(self): + return os.path.exists(DB) or super().detect() + + def list(self, cfg): + c = connect() + if not c: + return {} + try: + stats = {sid: (n, mx) for sid, n, mx in + c.execute('select session_id, count(*), coalesce(max(id), 0) from messages group by session_id')} + out = {} + for row in c.execute(f'select {FIELDS}, model_config from sessions where source=? ' + 'and parent_session_id is null and archived=0 and hidden=0', ('cli',)): + d = dict(zip(KEYS, row)) + if '_delegate_from' in (row[-1] or ''): + continue + n, mx = stats.get(d['id'], (0, 0)) + upd = int(max(d['last_activity_at'] or 0, d['started_at'] or 0) * 1000) + out['hermes:' + d['id']] = self.session( + d['id'], cwd=os.path.normpath(d['cwd'] or HOME), title=d['title'], + is_hop=self._is_hop(d['origin_json']), sig=f'{upd}:{n}:{mx}', updated=upd) + return out + finally: + c.close() + + def read(self, s, cfg): + c = connect() + try: + s['cwd'] = os.path.normpath(s['cwd']) if s.get('cwd') else None + row = c.execute('select cwd, title, origin_json from sessions where id=?', (s['id'],)).fetchone() + if row: + s['cwd'] = os.path.normpath(row[0] or HOME) + s['title'] = row[1] + s['is_hop'] = self._is_hop(row[2]) + rows = c.execute("select id, role, content, tool_calls, tool_name, timestamp from messages " + "where session_id=? and active=1 and role in ('user','assistant','tool') " + "order by timestamp, id", (s['id'],)).fetchall() + finally: + c.close() + els = [] + for mid, role, content, calls, tname, ts in rows: + ts = int((ts or 0) * 1000) + if role == 'tool': + els.append(el(mid, 'tool', fmt_result(content, cfg), ts)) + continue + if content: + els.append(el(mid, role, content, ts)) + for i, tc in enumerate(self._calls(calls)): + fn = tc.get('function') or {} + els.append(el(f'{mid}#t{i}', 'tool', fmt_call(fn.get('name') or tname, fn.get('arguments'), cfg), ts)) + return els + + def write(self, cp, cwd, msgs, title, ctx): + if not os.path.exists(DB): + # A missing store is permanent until the user runs Hermes once; a HopError stops sync from + # queueing this chat for retry on every turn (same as the Gemini and Kimi adapters). + raise HopError('run Hermes once so its state.db exists') + sid = cp['id'] if cp else new_session_id() + c = connect(rw=True) + try: + c.execute('PRAGMA foreign_keys=ON') + first = (msgs[0]['ts'] / 1000) if msgs else 0 + last = (msgs[-1]['ts'] / 1000) if msgs else 0 + with c: # one transaction: the delete and every insert land together, or not at all + if cp: + c.execute('delete from messages where session_id=?', (sid,)) + try: + self._session_row(c, cp, sid, cwd, title, first, last, len(msgs)) + except sqlite3.IntegrityError: + # Hermes requires unique titles: a colliding mirror gets a short suffix, never a blank title. + self._session_row(c, cp, sid, cwd, f'{title} · {sid[-6:]}', first, last, len(msgs)) + c.executemany('insert into messages (session_id, role, content, timestamp) values (?,?,?,?)', + [(sid, m['role'], m['text'], m['ts'] / 1000) for m in msgs]) + seen = [str(i) for (i,) in c.execute('select id from messages where session_id=? order by id', (sid,))] + return {'id': sid, 'seen': seen, **stat(c, sid)} + finally: + c.close() + + def _session_row(self, c, cp, sid, cwd, title, first, last, n): + if cp: + c.execute('update sessions set title=?, started_at=?, last_activity_at=?, message_count=? where id=?', + (title, first, last, n, sid)) + else: + # model is left NULL on purpose: `hermes --resume` reuses the session's stored model, and a + # placeholder like 'hop-import' is not a real provider model — it makes the mirror unresumable. + c.execute('insert into sessions (id, source, title, cwd, profile_name, started_at, ' + 'last_activity_at, message_count, origin_json) values (?,?,?,?,?,?,?,?,?)', + (sid, 'cli', title, cwd, profile_name(), first, last, n, ORIGIN)) + + def retitle(self, cp, title): + c = connect(rw=True) + if not c: + return False + try: + with c: + c.execute('update sessions set title=? where id=?', (title, cp['id'])) + except sqlite3.IntegrityError: + return False + finally: + c.close() + return True + + def delete(self, cp): + c = connect(rw=True) + if not c: + return + try: + with c: + c.execute('delete from messages where session_id=?', (cp['id'],)) # FK: children first + c.execute('delete from sessions where id=?', (cp['id'],)) + finally: + c.close() + + def resume_cmd(self, sid): + return ['hermes', '--resume', sid] + + def install(self, dry): + # Hermes' shell hooks need an allowlist plus a config.yaml edit a third-party installer should not make, + # so there is nothing safe to add here. Mirrors land on any other agent's sync, or `hop sync`. + return 'no hook support yet; synced when another agent (or `hop sync`) runs' + + @staticmethod + def _is_hop(origin_json): + try: + return json.loads(origin_json or '')['imported_from']['tool'] == HOP_ORIGIN + except (ValueError, KeyError, TypeError): + return False + + @staticmethod + def _calls(raw): + try: + calls = json.loads(raw or '[]') + except ValueError: + return [] + return [tc for tc in calls if isinstance(tc, dict)] diff --git a/src/samethread/agents/kimi.py b/src/samethread/agents/kimi.py index 19cb237..1f35e7f 100644 --- a/src/samethread/agents/kimi.py +++ b/src/samethread/agents/kimi.py @@ -7,7 +7,7 @@ import uuid from .. import hookkit -from ..core import HOME, HopError, dumps, el, file_sig, fmt_call, fmt_result, lp, read_json, read_jsonl, write_lines +from ..core import HOME, HopError, dumps, el, file_sig, fmt_call, fmt_result, iso_ms, lp, read_json, read_jsonl, write_lines from .base import Agent ROOT = os.path.join(HOME, '.kimi-code') @@ -54,7 +54,7 @@ def list(self, cfg): out['kimi:' + sid] = self.session(sid, path=wire, dir=d['sessionDir'], title=state.get('title'), cwd=os.path.normpath(state.get('cwd') or d.get('workDir') or HOME), is_hop=bool((state.get('custom') or {}).get('samethread')), - sig=file_sig(wire), updated=state.get('updatedAt') or 0) + sig=file_sig(wire), updated=iso_ms(state.get('updatedAt'))) return out def read(self, s, cfg): diff --git a/src/samethread/cli.py b/src/samethread/cli.py index db6bd8d..537a7e3 100644 --- a/src/samethread/cli.py +++ b/src/samethread/cli.py @@ -227,7 +227,7 @@ def main(): ('forget', cmd_forget, "delete a chat's mirrors and stop mirroring it")): p = sub.add_parser(name, help=hlp) if name == 'resume': - p.add_argument('agent', help='cc, oc, agy, codex, gemini, qwen, pi, kimi, mmx (or the full name)') + p.add_argument('agent', help='cc, oc, hermes, agy, codex, gemini, qwen, pi, kimi, mmx (or the full name)') if name == 'agy': p.set_defaults(agent='agy') if name != 'list': diff --git a/tests/test_core.py b/tests/test_core.py index ea7543b..83daa46 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,10 +1,12 @@ import json import os +import sqlite3 import tempfile import unittest TMP = tempfile.mkdtemp(prefix='hop-test-') -os.environ.update(HOP_HOME=os.path.join(TMP, 'hop'), HOME=TMP, USERPROFILE=TMP, CODEX_HOME=os.path.join(TMP, '.codex')) +os.environ.update(HOP_HOME=os.path.join(TMP, 'hop'), HOME=TMP, USERPROFILE=TMP, CODEX_HOME=os.path.join(TMP, '.codex'), + HERMES_HOME=os.path.join(TMP, '.hermes')) from samethread import core, sync # noqa: E402 from samethread.agents import AGENTS, NAMES, resolve # noqa: E402 @@ -43,7 +45,8 @@ def test_fmt_call_prefers_the_meaningful_argument(self): def test_agent_names_resolve(self): self.assertEqual(resolve('claude'), 'cc') self.assertEqual(resolve('MiniMax'), 'mmx') - self.assertEqual(len(NAMES), 9) + self.assertEqual(resolve('hermes'), 'hermes') + self.assertEqual(len(NAMES), 10) class Render(unittest.TestCase): @@ -162,5 +165,143 @@ def test_reads_transcript_steps(self): ('tool', '▸ run_command: npm test'), ('tool', ' ⎿ 1 failing')]) +HERMES_HOME_DIR = os.path.join(TMP, '.hermes') + +DDL = """ +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, model_config TEXT, + system_prompt TEXT, parent_session_id TEXT, + started_at REAL NOT NULL, ended_at REAL, end_reason TEXT, + message_count INTEGER DEFAULT 0, title TEXT, title_source TEXT, + cwd TEXT, last_activity_at REAL, origin_json TEXT, + profile_name TEXT, archived INTEGER NOT NULL DEFAULT 0, hidden INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id)); +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, + timestamp REAL NOT NULL, active INTEGER NOT NULL DEFAULT 1); +CREATE UNIQUE INDEX idx_sessions_title_unique ON sessions(title) WHERE title IS NOT NULL; +""" + + +class HermesStore(unittest.TestCase): + """The store must be shaped exactly like the real $HERMES_HOME/state.db (title index included).""" + + @classmethod + def setUpClass(cls): + os.makedirs(HERMES_HOME_DIR, exist_ok=True) + c = sqlite3.connect(os.path.join(HERMES_HOME_DIR, 'state.db')) + c.executescript(DDL) + c.commit() + c.close() + + def conn(self): + return sqlite3.connect(os.path.join(HERMES_HOME_DIR, 'state.db')) + + def setUp(self): + # Every test starts from an empty store: no test may depend on another having run first. + c = self.conn() + c.executescript('delete from messages; delete from sessions;') + c.commit() + c.close() + + def test_mirror_roundtrips_and_collides_on_title(self): + agent, th = AGENTS['hermes'], thread([e('1', 'user', 'remember MANGO'), e('2', 'assistant', 'stored')]) + res = agent.write(None, os.getcwd(), sync.render(th, CFG), 'Hermes (Agy)', {'cfg': CFG}) + s = agent.list(CFG)['hermes:' + res['id']] + els = sync.load(s, CFG) + self.assertEqual({x['id'] for x in els}, set(res['seen'])) + self.assertEqual(els[-1]['text'], 'stored') + self.assertIn('remember MANGO', els[0]['text']) + self.assertTrue(s['is_hop']) + self.assertEqual(s['sig'], res['sig']) + again = agent.write(None, os.getcwd(), sync.render(th, CFG), 'Hermes (Agy)', {'cfg': CFG}) # same title + self.assertNotEqual(again['id'], res['id']) + c = self.conn() + count = c.execute('select count(*) from messages where session_id=?', (res['id'],)).fetchone()[0] + prof = c.execute('select profile_name, model, source from sessions where id=?', (res['id'],)).fetchone() + c.close() + self.assertEqual(count, len(res['seen'])) + # model must stay NULL: hermes --resume reuses the stored model, and a placeholder + # ('hop-import') is not a provider model, which makes the mirror unresumable. + self.assertEqual(prof, ('default', None, 'cli')) + + def test_reads_a_real_user_session(self): + c = self.conn() + c.execute("insert into sessions (id, source, started_at, title, cwd) values ('real1','cli',1.0,'My chat','/tmp')") + c.executemany('insert into messages (session_id, role, content, timestamp, tool_calls, tool_name) ' + 'values (?,?,?,?,?,?)', + [('real1', 'user', 'fix it', 2.0, None, None), + ('real1', 'assistant', 'on it', 3.0, + '[{"id":"t1","type":"function","function":{"name":"Bash","arguments":"{\\"command\\":\\"ls\\"}"}}]', None), + ('real1', 'tool', 'a.py', 4.0, None, 'Bash'), + ('real1', 'session_meta', 'hidden', 5.0, None, None)]) + c.commit() + c.close() + s = AGENTS['hermes'].list(CFG)['hermes:real1'] + self.assertFalse(s['is_hop']) + els = sync.load(s, CFG) + self.assertEqual([(x['role'], x['text']) for x in els], + [('user', 'fix it'), ('assistant', 'on it'), ('tool', '▸ Bash: ls'), ('tool', ' ⎿ a.py')]) + + def test_reads_a_wal_store_without_sidecars(self): + """Hermes exits cleanly, leaving a WAL-mode db with no -wal/-shm; list() must still open it.""" + c = self.conn() + c.execute("insert into sessions (id, source, started_at, title, cwd) values ('wal1','cli',1.0,'W','/tmp')") + c.execute("insert into messages (session_id, role, content, timestamp) values ('wal1','user','hi',2.0)") + c.commit() + c.execute('PRAGMA journal_mode=WAL') + c.close() # sidecars are removed on close + self.assertFalse(os.path.exists(os.path.join(HERMES_HOME_DIR, 'state.db-wal'))) + self.assertIn('hermes:wal1', AGENTS['hermes'].list(CFG)) + + def test_missing_store_raises_hop_error(self): + """Hermes installed but never run: a HopError, not a raw OperationalError, so sync stops retrying.""" + db = os.path.join(HERMES_HOME_DIR, 'state.db') + os.rename(db, db + '.away') # keep the store for later tests + try: + with self.assertRaises(core.HopError): + AGENTS['hermes'].write(None, os.getcwd(), sync.render(thread([e('1', 'user', 'x')]), CFG), + 'T', {'cfg': CFG}) + AGENTS['hermes'].delete({'id': 'whatever'}) # must be a no-op, not a crash + self.assertEqual(AGENTS['hermes'].list(CFG), {}) + finally: + os.rename(db + '.away', db) + + def test_title_collision_gets_a_suffix_not_a_blank(self): + """Hermes requires unique titles; a second mirror must keep a title (suffixed), never go untitled.""" + agent, th = AGENTS['hermes'], thread([e('1', 'user', 'a'), e('2', 'assistant', 'b')]) + msgs, title = sync.render(th, CFG), 'Greeting (OpenCode)' + res = agent.write(None, os.getcwd(), msgs, title, {'cfg': CFG}) + res2 = agent.write(None, os.getcwd(), msgs, title, {'cfg': CFG}) + c = self.conn() + t1 = c.execute('select title from sessions where id=?', (res['id'],)).fetchone()[0] + t2 = c.execute('select title from sessions where id=?', (res2['id'],)).fetchone()[0] + c.close() + self.assertEqual(t1, title) + self.assertTrue(t2 and t2.startswith(title) and t2 != title, t2) + + +class KimiIndex(unittest.TestCase): + """A real Kimi state.json stores updatedAt as an ISO string; list() must hand sync an int (ms).""" + + def test_updated_at_is_converted_to_epoch_ms(self): + sdir = os.path.join(TMP, '.kimi-code', 'sessions', 'wd_x_abc', 'session_iso1') + os.makedirs(os.path.join(sdir, 'agents', 'main'), exist_ok=True) + with open(os.path.join(sdir, 'agents', 'main', 'wire.jsonl'), 'w') as f: + f.write(core.dumps({'type': 'context.append_message', 'time': 1, 'message': { + 'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}], + 'origin': {'kind': 'user'}, 'id': 'm1'}}) + '\n') + with open(os.path.join(sdir, 'state.json'), 'w') as f: + f.write(core.dumps({'id': 'session_iso1', 'archived': False, 'cwd': '/tmp', + 'updatedAt': '2026-07-18T13:10:14.672Z', 'title': 'T'})) + with open(os.path.join(TMP, '.kimi-code', 'session_index.jsonl'), 'a') as f: + f.write(core.dumps({'sessionId': 'session_iso1', 'sessionDir': sdir, 'workDir': '/tmp'}) + '\n') + s = AGENTS['kimi'].list(CFG)['kimi:session_iso1'] + self.assertIsInstance(s['updated'], int) + self.assertEqual(s['updated'], core.iso_ms('2026-07-18T13:10:14.672Z')) + sorted([s], key=lambda x: x['updated']) # sync.adopt_new's sort key must not raise + + if __name__ == '__main__': unittest.main()