also looking at this
feat: let a text switch one thread between Claude and Codex
#52
16 files
+316
-27
| @@ -70,5 +70,17 @@ export default defineModel({ | ||
| 70 | 70 | validation: { rule: schema.string() }, |
| 71 | 71 | factory: () => '', |
| 72 | 72 | }, |
| 73 | ||
| 74 | /** | |
| 75 | * The engine this thread was switched to, or null to follow whatever the | |
| 76 | * installation is set to. Null is the point: a thread that never asked | |
| 77 | * keeps tracking the menubar's picker, so changing it there still works. | |
| 78 | */ | |
| 79 | engine: { | |
| 80 | order: 8, | |
| 81 | fillable: true, | |
| 82 | validation: { rule: schema.enum(['claude', 'codex']) }, | |
| 83 | factory: () => 'claude', | |
| 84 | }, | |
| 73 | 85 | }, |
| 74 | 86 | } as const) |
| @@ -110,5 +110,17 @@ export default defineModel({ | ||
| 110 | 110 | validation: { rule: schema.number() }, |
| 111 | 111 | factory: () => Date.now(), |
| 112 | 112 | }, |
| 113 | ||
| 114 | /** | |
| 115 | * Which agent answered. Nullable rather than defaulted: rows written | |
| 116 | * before a thread could choose predate the question, and calling them all | |
| 117 | * Claude would be a guess recorded as a fact. | |
| 118 | */ | |
| 119 | engine: { | |
| 120 | order: 14, | |
| 121 | fillable: true, | |
| 122 | validation: { rule: schema.enum(['claude', 'codex']) }, | |
| 123 | factory: faker => faker.helpers.arrayElement(['claude', 'codex']), | |
| 124 | }, | |
| 113 | 125 | }, |
| 114 | 126 | } as const) |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * message matches ("stop", "/stop", "Stop."), so "stop the dev server in |
| 4 | 4 | * ~/Code/x" still reaches the agent as a task. |
| 5 | 5 | */ |
| 6 | export type ControlCommand = 'help' | 'status' | 'stop' | 'new' | 'more' | 'ping' | |
| 6 | export type ControlCommand = 'help' | 'status' | 'stop' | 'new' | 'more' | 'ping' | 'claude' | 'codex' | |
| 7 | 7 | |
| 8 | 8 | const ALIASES: Record<string, ControlCommand> = { |
| 9 | 9 | 'help': 'help', |
| @@ -19,6 +19,10 @@ const ALIASES: Record<string, ControlCommand> = { | ||
| 19 | 19 | 'more': 'more', |
| 20 | 20 | 'continue reply': 'more', |
| 21 | 21 | 'ping': 'ping', |
| 22 | // Switching engines is a whole-message word like the rest, so "codex keeps | |
| 23 | // timing out on ~/Code/x" is still a task and not a switch. | |
| 24 | 'claude': 'claude', | |
| 25 | 'codex': 'codex', | |
| 22 | 26 | } |
| 23 | 27 | |
| 24 | 28 | export function parseControl(text: string): ControlCommand | null { |
| @@ -33,4 +37,5 @@ export const HELP_TEXT = [ | ||
| 33 | 37 | 'new - start a fresh conversation', |
| 34 | 38 | 'more - rest of a long reply', |
| 35 | 39 | 'ping - check I am alive', |
| 40 | 'claude / codex - switch this thread to that agent', | |
| 36 | 41 | ].join('\n') |
| @@ -8,7 +8,7 @@ import { homedir } from 'node:os' | ||
| 8 | 8 | import { join } from 'node:path' |
| 9 | 9 | import process from 'node:process' |
| 10 | 10 | import { loadConfig } from './config' |
| 11 | import { allEngines, selectedEngine } from './engines' | |
| 11 | import { allEngines, engineFor, selectedEngine } from './engines' | |
| 12 | 12 | import { formatDuration, truncate } from './format' |
| 13 | 13 | import { MessagesAccessError, MessagesDb } from './messages-db' |
| 14 | 14 | import { AppleScriptSender } from './sender' |
| @@ -300,7 +300,9 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 300 | 300 | // license. |
| 301 | 301 | if (!isLicensed(license)) |
| 302 | 302 | return |
| 303 | uplink = new Uplink({ config, messages, sender, engine: selectedEngine(config), store }) | |
| 303 | // `engineFor` as well as `engine`, so a thread that texts "codex" can | |
| 304 | // reach the other one without restarting the agent. | |
| 305 | uplink = new Uplink({ config, messages, sender, engine: selectedEngine(config), engineFor: id => engineFor(id, config), store }) | |
| 304 | 306 | await uplink.start() |
| 305 | 307 | } |
| 306 | 308 | |
| @@ -38,6 +38,7 @@ export class ModelStore implements Store { | ||
| 38 | 38 | cwd: row.cwd ?? null, |
| 39 | 39 | lastActiveAt: row.last_active_at ?? null, |
| 40 | 40 | moreText: row.more_text ?? null, |
| 41 | engine: row.engine ?? null, | |
| 41 | 42 | } |
| 42 | 43 | } |
| 43 | 44 | |
| @@ -49,6 +50,7 @@ export class ModelStore implements Store { | ||
| 49 | 50 | cwd: conversation.cwd, |
| 50 | 51 | lastActiveAt: conversation.lastActiveAt, |
| 51 | 52 | moreText: conversation.moreText, |
| 53 | engine: conversation.engine, | |
| 52 | 54 | } |
| 53 | 55 | const existing = await Conversation.where('chat_guid', conversation.chatGuid).first() as { update: (v: object) => Promise<unknown> } | undefined |
| 54 | 56 | if (existing) |
| @@ -37,7 +37,7 @@ export interface RunRow extends RunRecord { | ||
| 37 | 37 | * old it is, and so the day a change needs more than a new column - a backfill, |
| 38 | 38 | * a rename - has a version to key off. |
| 39 | 39 | */ |
| 40 | const SCHEMA_VERSION = 1 | |
| 40 | const SCHEMA_VERSION = 2 | |
| 41 | 41 | |
| 42 | 42 | export interface TableSpec { |
| 43 | 43 | name: string |
| @@ -78,6 +78,7 @@ export const TABLES: TableSpec[] = [ | ||
| 78 | 78 | cwd: 'TEXT', |
| 79 | 79 | last_active_at: 'INTEGER', |
| 80 | 80 | more_text: 'TEXT', |
| 81 | engine: 'TEXT', | |
| 81 | 82 | }, |
| 82 | 83 | }, |
| 83 | 84 | { |
| @@ -97,6 +98,7 @@ export const TABLES: TableSpec[] = [ | ||
| 97 | 98 | last_activity: 'TEXT', |
| 98 | 99 | started_at: 'INTEGER', |
| 99 | 100 | finished_at: 'INTEGER', |
| 101 | engine: 'TEXT', | |
| 100 | 102 | }, |
| 101 | 103 | }, |
| 102 | 104 | ] |
| @@ -115,6 +117,7 @@ export const RUN_COLUMNS: Record<keyof RunRecord, string> = { | ||
| 115 | 117 | lastActivity: 'last_activity', |
| 116 | 118 | startedAt: 'started_at', |
| 117 | 119 | finishedAt: 'finished_at', |
| 120 | engine: 'engine', | |
| 118 | 121 | } |
| 119 | 122 | |
| 120 | 123 | /** |
| @@ -236,17 +239,19 @@ export class SqliteStore implements Store { | ||
| 236 | 239 | cwd: row.cwd, |
| 237 | 240 | lastActiveAt: row.last_active_at, |
| 238 | 241 | moreText: row.more_text, |
| 242 | engine: row.engine, | |
| 239 | 243 | } |
| 240 | 244 | } |
| 241 | 245 | |
| 242 | 246 | async saveConversation(c: ConversationRecord): Promise<void> { |
| 243 | 247 | this.db.query(` |
| 244 | INSERT INTO conversations (chat_guid, handle, service, session_id, cwd, last_active_at, more_text) | |
| 245 | VALUES (?, ?, ?, ?, ?, ?, ?) | |
| 248 | INSERT INTO conversations (chat_guid, handle, service, session_id, cwd, last_active_at, more_text, engine) | |
| 249 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 246 | 250 | ON CONFLICT(chat_guid) DO UPDATE SET |
| 247 | 251 | handle = excluded.handle, service = excluded.service, session_id = excluded.session_id, |
| 248 | cwd = excluded.cwd, last_active_at = excluded.last_active_at, more_text = excluded.more_text | |
| 249 | `).run(c.chatGuid, c.handle, c.service, c.sessionId, c.cwd, c.lastActiveAt, c.moreText) | |
| 252 | cwd = excluded.cwd, last_active_at = excluded.last_active_at, more_text = excluded.more_text, | |
| 253 | engine = excluded.engine | |
| 254 | `).run(c.chatGuid, c.handle, c.service, c.sessionId, c.cwd, c.lastActiveAt, c.moreText, c.engine) | |
| 250 | 255 | } |
| 251 | 256 | |
| 252 | 257 | async createRun(run: RunRecord): Promise<number> { |
| @@ -283,6 +288,7 @@ export class SqliteStore implements Store { | ||
| 283 | 288 | lastActivity: row.last_activity, |
| 284 | 289 | startedAt: row.started_at, |
| 285 | 290 | finishedAt: row.finished_at, |
| 291 | engine: row.engine, | |
| 286 | 292 | })) |
| 287 | 293 | } |
| 288 | 294 | } |
| @@ -4,6 +4,8 @@ | ||
| 4 | 4 | * the model-backed store in `model-store.ts`, tests use `MemoryStore`. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { EngineId } from './engine' | |
| 8 | ||
| 7 | 9 | export type RunStatus = 'queued' | 'running' | 'done' | 'failed' | 'stopped' |
| 8 | 10 | |
| 9 | 11 | export interface ConversationRecord { |
| @@ -15,6 +17,12 @@ export interface ConversationRecord { | ||
| 15 | 17 | lastActiveAt: number | null |
| 16 | 18 | /** The unsent tail of the last long reply, released by "more". */ |
| 17 | 19 | moreText: string | null |
| 20 | /** | |
| 21 | * The engine this thread was switched to, or null to follow the | |
| 22 | * installation's setting. Null rather than a default so a thread that never | |
| 23 | * asked still tracks the menubar's picker. | |
| 24 | */ | |
| 25 | engine: EngineId | null | |
| 18 | 26 | } |
| 19 | 27 | |
| 20 | 28 | export interface RunRecord { |
| @@ -31,6 +39,8 @@ export interface RunRecord { | ||
| 31 | 39 | lastActivity?: string | null |
| 32 | 40 | startedAt?: number | null |
| 33 | 41 | finishedAt?: number | null |
| 42 | /** Which agent answered, recorded per run because a thread can switch. */ | |
| 43 | engine?: EngineId | null | |
| 34 | 44 | } |
| 35 | 45 | |
| 36 | 46 | export interface Store { |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { ControlCommand } from './commands' |
| 2 | 2 | import type { UplinkConfig } from './config' |
| 3 | import type { Engine, EngineRun } from './engine' | |
| 3 | import type { Engine, EngineId, EngineRun } from './engine' | |
| 4 | 4 | import type { IncomingMessage } from './messages-db' |
| 5 | 5 | import type { ReplyTarget, Sender } from './sender' |
| 6 | 6 | import type { ConversationRecord, Store } from './store' |
| @@ -36,7 +36,13 @@ export interface UplinkDeps { | ||
| 36 | 36 | config: UplinkConfig |
| 37 | 37 | messages: MessagesSource |
| 38 | 38 | sender: Sender |
| 39 | /** The installation's engine: what a thread uses until it says otherwise. */ | |
| 39 | 40 | engine: Engine |
| 41 | /** | |
| 42 | * How to reach the other engine when a thread texts its name. Left out, every | |
| 43 | * thread gets `engine`, which is what a test with one fake wants. | |
| 44 | */ | |
| 45 | engineFor?: (id: EngineId) => Engine | |
| 40 | 46 | store: Store |
| 41 | 47 | systemPrompt?: string |
| 42 | 48 | log?: Logger |
| @@ -48,6 +54,12 @@ interface Job { | ||
| 48 | 54 | prompt: string |
| 49 | 55 | cwd: string |
| 50 | 56 | target: ReplyTarget |
| 57 | /** | |
| 58 | * Fixed when the text arrived, not read when the run starts: a task queued | |
| 59 | * behind another runs on the engine it was sent to, even if the thread | |
| 60 | * switched while it waited. | |
| 61 | */ | |
| 62 | engine: EngineId | |
| 51 | 63 | } |
| 52 | 64 | |
| 53 | 65 | interface ActiveJob extends Job { |
| @@ -316,6 +328,7 @@ export class Uplink { | ||
| 316 | 328 | cwd: null, |
| 317 | 329 | lastActiveAt: null, |
| 318 | 330 | moreText: null, |
| 331 | engine: null, | |
| 319 | 332 | } |
| 320 | 333 | } |
| 321 | 334 | |
| @@ -323,16 +336,18 @@ export class Uplink { | ||
| 323 | 336 | const conversation = await this.conversation(target) |
| 324 | 337 | const cwd = detectWorkdir(prompt, this.threadIsFresh(conversation) && conversation.cwd ? conversation.cwd : this.config.workdir) |
| 325 | 338 | |
| 339 | const engine = conversation.engine ?? this.config.engine | |
| 326 | 340 | const runId = await this.deps.store.createRun({ |
| 327 | 341 | chatGuid: target.chatGuid, |
| 328 | 342 | messageGuid: message.guid, |
| 329 | 343 | prompt, |
| 330 | 344 | cwd, |
| 331 | 345 | status: 'queued', |
| 346 | engine, | |
| 332 | 347 | }) |
| 333 | 348 | |
| 334 | 349 | const state = this.chat(target.chatGuid) |
| 335 | const job: Job = { runId, prompt, cwd, target } | |
| 350 | const job: Job = { runId, prompt, cwd, target, engine } | |
| 336 | 351 | if (state.active) { |
| 337 | 352 | state.queue.push(job) |
| 338 | 353 | await this.reply(target, `Queued behind the current task (${state.queue.length} waiting). Text "status" or "stop".`) |
| @@ -381,19 +396,27 @@ export class Uplink { | ||
| 381 | 396 | } |
| 382 | 397 | } |
| 383 | 398 | |
| 399 | /** The engine behind an id, falling back to the installation's one. */ | |
| 400 | private engineOf(id: EngineId): Engine { | |
| 401 | return this.deps.engineFor?.(id) ?? this.deps.engine | |
| 402 | } | |
| 403 | ||
| 384 | 404 | private threadIsFresh(conversation: ConversationRecord): boolean { |
| 385 | 405 | return conversation.lastActiveAt !== null && this.now() - conversation.lastActiveAt < this.config.sessionIdleMs |
| 386 | 406 | } |
| 387 | 407 | |
| 388 | 408 | private async begin(state: ChatState, job: Job): Promise<void> { |
| 389 | 409 | const conversation = await this.conversation(job.target) |
| 390 | const resume = conversation.sessionId && conversation.cwd === job.cwd && this.threadIsFresh(conversation) | |
| 410 | // The engine has to agree as well: a session id belongs to the agent that | |
| 411 | // made it, and handing a Claude one to Codex resumes nothing. | |
| 412 | const sameEngine = (conversation.engine ?? this.config.engine) === job.engine | |
| 413 | const resume = conversation.sessionId && conversation.cwd === job.cwd && sameEngine && this.threadIsFresh(conversation) | |
| 391 | 414 | ? conversation.sessionId |
| 392 | 415 | : null |
| 393 | 416 | |
| 394 | 417 | await this.deps.store.updateRun(job.runId, { status: 'running', startedAt: this.now() }) |
| 395 | 418 | |
| 396 | const run = this.deps.engine.run({ | |
| 419 | const run = this.engineOf(job.engine).run({ | |
| 397 | 420 | prompt: job.prompt, |
| 398 | 421 | cwd: job.cwd, |
| 399 | 422 | sessionId: resume, |
| @@ -460,7 +483,7 @@ export class Uplink { | ||
| 460 | 483 | // Naming the wrong CLI, or a .env the downloadable app does not have, |
| 461 | 484 | // sends someone at the Mac to fix something that is not broken. The |
| 462 | 485 | // engine carries its own remedy. |
| 463 | const engine = this.deps.engine | |
| 486 | const engine = this.engineOf(active.engine) | |
| 464 | 487 | const text = result.authFailure |
| 465 | 488 | ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.` |
| 466 | 489 | : result.ok ? result.text : `That failed: ${result.text}` |
| @@ -499,13 +522,19 @@ export class Uplink { | ||
| 499 | 522 | return |
| 500 | 523 | |
| 501 | 524 | case 'status': { |
| 525 | // A thread that can switch has to be able to say what it is on. | |
| 526 | const conversation = await this.conversation(target) | |
| 527 | const thread = this.engineOf(conversation.engine ?? this.config.engine).label | |
| 502 | 528 | if (!state.active) { |
| 503 | await this.reply(target, 'Idle. Text me a question or a task.') | |
| 529 | await this.reply(target, `Idle on ${thread}. Text me a question or a task.`) | |
| 504 | 530 | return |
| 505 | 531 | } |
| 506 | 532 | const active = state.active |
| 507 | 533 | const lines = [ |
| 508 | 534 | `Working ${formatDuration(this.now() - active.startedAt)} on: ${truncate(active.prompt, 80)}`, |
| 535 | // The running task's engine, which is not always the thread's: it | |
| 536 | // keeps whatever it was sent to. | |
| 537 | `Agent: ${this.engineOf(active.engine).label}`, | |
| 509 | 538 | active.lastActivity ? `Latest: ${active.lastActivity}` : 'Thinking.', |
| 510 | 539 | ] |
| 511 | 540 | if (state.queue.length > 0) |
| @@ -525,6 +554,35 @@ export class Uplink { | ||
| 525 | 554 | return |
| 526 | 555 | } |
| 527 | 556 | |
| 557 | case 'claude': | |
| 558 | case 'codex': { | |
| 559 | const conversation = await this.conversation(target) | |
| 560 | const engine = this.engineOf(command) | |
| 561 | if ((conversation.engine ?? this.config.engine) === command) { | |
| 562 | await this.reply(target, `Already on ${engine.label}.`) | |
| 563 | return | |
| 564 | } | |
| 565 | ||
| 566 | // Asked now rather than when the next text fails. Someone switching | |
| 567 | // from a phone cannot see that the CLI is missing, and finding out on | |
| 568 | // their real task costs them the task. | |
| 569 | const probe = await engine.probe() | |
| 570 | if (!probe.ok) { | |
| 571 | await this.reply(target, probe.reason === 'missing' | |
| 572 | ? `${engine.label} is not installed on this Mac. Install it there with: ${engine.install.command}` | |
| 573 | : `${engine.label} is not ready: ${probe.detail}`) | |
| 574 | return | |
| 575 | } | |
| 576 | ||
| 577 | // The session id goes with it. It belongs to the agent that made it, | |
| 578 | // and the other one cannot resume it, so this starts a fresh | |
| 579 | // conversation the way "new" does. The working directory stays: it is | |
| 580 | // not the agent's, it is the thread's. | |
| 581 | await this.deps.store.saveConversation({ ...conversation, engine: command, sessionId: null }) | |
| 582 | await this.reply(target, `Switched to ${engine.label}. Starting a fresh conversation, because a session cannot move between agents.`) | |
| 583 | return | |
| 584 | } | |
| 585 | ||
| 528 | 586 | case 'more': { |
| 529 | 587 | const conversation = await this.conversation(target) |
| 530 | 588 | if (!conversation.moreText) { |
| @@ -42,6 +42,7 @@ const words = [ | ||
| 42 | 42 | { word: 'new', does: 'start a fresh conversation' }, |
| 43 | 43 | { word: 'more', does: 'the rest of a long reply' }, |
| 44 | 44 | { word: 'ping', does: 'check it is alive' }, |
| 45 | { word: 'claude / codex', does: 'switch that thread to that agent' }, | |
| 45 | 46 | ] |
| 46 | 47 | |
| 47 | 48 | import release from '../data/release.json' |
| @@ -90,6 +90,18 @@ | ||
| 90 | 90 | "isUnique": false, |
| 91 | 91 | "isNullable": true, |
| 92 | 92 | "hasDefault": false |
| 93 | }, | |
| 94 | { | |
| 95 | "name": "engine", | |
| 96 | "type": "enum", | |
| 97 | "isPrimaryKey": false, | |
| 98 | "isUnique": false, | |
| 99 | "isNullable": true, | |
| 100 | "hasDefault": false, | |
| 101 | "enumValues": [ | |
| 102 | "claude", | |
| 103 | "codex" | |
| 104 | ] | |
| 93 | 105 | } |
| 94 | 106 | ], |
| 95 | 107 | "indexes": [ |
| @@ -257,6 +269,18 @@ | ||
| 257 | 269 | "isUnique": false, |
| 258 | 270 | "isNullable": true, |
| 259 | 271 | "hasDefault": false |
| 272 | }, | |
| 273 | { | |
| 274 | "name": "engine", | |
| 275 | "type": "enum", | |
| 276 | "isPrimaryKey": false, | |
| 277 | "isUnique": false, | |
| 278 | "isNullable": true, | |
| 279 | "hasDefault": false, | |
| 280 | "enumValues": [ | |
| 281 | "claude", | |
| 282 | "codex" | |
| 283 | ] | |
| 260 | 284 | } |
| 261 | 285 | ], |
| 262 | 286 | "indexes": [] |
| @@ -392,7 +416,7 @@ | ||
| 392 | 416 | } |
| 393 | 417 | ] |
| 394 | 418 | }, |
| 395 | "hash": "cf904d0837380746ef9f95409635f2960b57dbac539b69229ea1da12f911d4b5", | |
| 419 | "hash": "3273a7f2b7ad412f972eb4decd23ad15b2cb9ac86d757bd5f520ed81f2e3d919", | |
| 396 | 420 | "dialect": "sqlite", |
| 397 | "updatedAt": "2026-09-28T21:27:50.923Z" | |
| 421 | "updatedAt": "2026-09-29T15:42:55.226Z" | |
| 398 | 422 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 1 | import type { Engine, EngineEvent, EngineId, EngineInstall, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 2 | 2 | import type { AutomationState, ReplyTarget, Sender } from '../../../app/Uplink/sender' |
| 3 | 3 | import { Database } from 'bun:sqlite' |
| 4 | 4 | import { mkdtempSync } from 'node:fs' |
| @@ -153,12 +153,25 @@ export class FakeEngine implements Engine { | ||
| 153 | 153 | runs: PendingRun[] = [] |
| 154 | 154 | probes = 0 |
| 155 | 155 | |
| 156 | // Identity matching the real Claude engine, so a test that asserts on a | |
| 157 | // reply naming the CLI is asserting the text a user would actually get. | |
| 158 | readonly id = 'claude' as const | |
| 159 | readonly label = 'Claude Code' | |
| 160 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 161 | readonly install = { command: 'bun install -g @anthropic-ai/claude-code', url: 'https://docs.claude.com/en/docs/claude-code' } | |
| 156 | // Identity matching the real engines, so a test that asserts on a reply | |
| 157 | // naming the CLI is asserting the text a user would actually get. Defaults | |
| 158 | // to Claude, which is what a test with one engine wants. | |
| 159 | readonly id: EngineId | |
| 160 | readonly label: string | |
| 161 | readonly authFailureHint: string | |
| 162 | readonly install: EngineInstall | |
| 163 | ||
| 164 | constructor(id: EngineId = 'claude') { | |
| 165 | this.id = id | |
| 166 | const claude = id === 'claude' | |
| 167 | this.label = claude ? 'Claude Code' : 'Codex' | |
| 168 | this.authFailureHint = claude | |
| 169 | ? 'run "claude setup-token" on the Mac and give Uplink the token' | |
| 170 | : 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)' | |
| 171 | this.install = claude | |
| 172 | ? { command: 'bun install -g @anthropic-ai/claude-code', url: 'https://docs.claude.com/en/docs/claude-code' } | |
| 173 | : { command: 'bun install -g @openai/codex', url: 'https://developers.openai.com/codex/cli' } | |
| 174 | } | |
| 162 | 175 | |
| 163 | 176 | probeResult: EngineProbe = { ok: true, reason: 'ok', detail: 'Signed in' } |
| 164 | 177 | |
| @@ -30,7 +30,7 @@ describe('SqliteStore', () => { | ||
| 30 | 30 | await store.setCursor(42) |
| 31 | 31 | expect(await store.cursor()).toBe(42) |
| 32 | 32 | |
| 33 | const conversation = { chatGuid: 'g', handle: ME, service: 'iMessage', sessionId: 's', cwd: '/tmp', lastActiveAt: 5, moreText: 'x'.repeat(3000) } | |
| 33 | const conversation = { chatGuid: 'g', handle: ME, service: 'iMessage', sessionId: 's', cwd: '/tmp', lastActiveAt: 5, moreText: 'x'.repeat(3000), engine: null } | |
| 34 | 34 | await store.saveConversation(conversation) |
| 35 | 35 | await store.saveConversation({ ...conversation, sessionId: 's2' }) |
| 36 | 36 | expect(await store.conversation('g')).toEqual({ ...conversation, sessionId: 's2' }) |
| @@ -20,9 +20,13 @@ function config(overrides: Partial<UplinkConfig> = {}): UplinkConfig { | ||
| 20 | 20 | progressEveryMs: 0, |
| 21 | 21 | sessionIdleMs: 6 * 3_600_000, |
| 22 | 22 | workdir: '/tmp', |
| 23 | engine: 'claude', | |
| 23 | 24 | claudeBin: 'claude', |
| 24 | model: null, | |
| 25 | claudeModel: null, | |
| 25 | 26 | permissionMode: 'bypassPermissions', |
| 27 | codexBin: 'codex', | |
| 28 | codexModel: null, | |
| 29 | codexPermission: 'bypass', | |
| 26 | 30 | timeoutMs: 60_000, |
| 27 | 31 | ...overrides, |
| 28 | 32 | } |
| @@ -32,6 +36,8 @@ interface Harness { | ||
| 32 | 36 | fake: FakeChatDb |
| 33 | 37 | sender: FakeSender |
| 34 | 38 | engine: FakeEngine |
| 39 | /** The other agent, reachable once a thread texts its name. */ | |
| 40 | codex: FakeEngine | |
| 35 | 41 | store: MemoryStore |
| 36 | 42 | uplink: Uplink |
| 37 | 43 | } |
| @@ -44,10 +50,19 @@ async function harness(overrides: Partial<UplinkConfig> = {}, seed?: (fake: Fake | ||
| 44 | 50 | seed?.(fake) |
| 45 | 51 | const sender = new FakeSender(fake) |
| 46 | 52 | const engine = new FakeEngine() |
| 53 | const codex = new FakeEngine('codex') | |
| 47 | 54 | const store = new MemoryStore() |
| 48 | const uplink = new Uplink({ config: config(overrides), messages: new MessagesDb(fake.path), sender, engine, store, log: silent }) | |
| 55 | const uplink = new Uplink({ | |
| 56 | config: config(overrides), | |
| 57 | messages: new MessagesDb(fake.path), | |
| 58 | sender, | |
| 59 | engine, | |
| 60 | engineFor: id => (id === 'codex' ? codex : engine), | |
| 61 | store, | |
| 62 | log: silent, | |
| 63 | }) | |
| 49 | 64 | await uplink.start({ poll: false }) // Each test drives tick() itself. |
| 50 | return { fake, sender, engine, store, uplink } | |
| 65 | return { fake, sender, engine, codex, store, uplink } | |
| 51 | 66 | } |
| 52 | 67 | |
| 53 | 68 | afterEach(() => open?.close()) |
| @@ -244,6 +259,128 @@ describe('Uplink', () => { | ||
| 244 | 259 | expect(h.store.runs.get(1)?.status).toBe('failed') |
| 245 | 260 | }) |
| 246 | 261 | |
| 262 | /** | |
| 263 | * #10: a global setting cannot answer "use Codex for this one thread" from a | |
| 264 | * phone, which is the point of the product. | |
| 265 | */ | |
| 266 | it('switches one thread to the other agent, and does not carry the session across', async () => { | |
| 267 | const h = await harness() | |
| 268 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) | |
| 269 | await h.uplink.tick() | |
| 270 | h.engine.finish(0, 'hi', { sessionId: 'claude-session-1' }) | |
| 271 | await settle() | |
| 272 | ||
| 273 | h.fake.add({ chat: ME, text: 'codex', fromMe: true }) | |
| 274 | await h.uplink.tick() | |
| 275 | expect(h.sender.texts().at(-1)).toContain('Switched to Codex') | |
| 276 | ||
| 277 | h.fake.add({ chat: ME, text: 'now do the thing', fromMe: true }) | |
| 278 | await h.uplink.tick() | |
| 279 | ||
| 280 | // The task went to the other agent, and Claude was not asked again. | |
| 281 | expect(h.codex.runs).toHaveLength(1) | |
| 282 | expect(h.engine.runs).toHaveLength(1) | |
| 283 | // A Claude session id cannot be resumed by Codex, so it is not offered. | |
| 284 | expect(h.codex.runs[0].request.sessionId).toBeNull() | |
| 285 | expect(h.store.runs.get(2)?.engine).toBe('codex') | |
| 286 | }) | |
| 287 | ||
| 288 | it('keeps the thread on the new agent, and switches back when asked', async () => { | |
| 289 | const h = await harness() | |
| 290 | h.fake.add({ chat: ME, text: 'codex', fromMe: true }) | |
| 291 | await h.uplink.tick() | |
| 292 | h.fake.add({ chat: ME, text: 'one', fromMe: true }) | |
| 293 | await h.uplink.tick() | |
| 294 | h.codex.finish(0, 'done') | |
| 295 | await settle() | |
| 296 | ||
| 297 | // Still Codex without being told again. | |
| 298 | h.fake.add({ chat: ME, text: 'two', fromMe: true }) | |
| 299 | await h.uplink.tick() | |
| 300 | expect(h.codex.runs).toHaveLength(2) | |
| 301 | h.codex.finish(1, 'done') | |
| 302 | await settle() | |
| 303 | ||
| 304 | h.fake.add({ chat: ME, text: 'claude', fromMe: true }) | |
| 305 | await h.uplink.tick() | |
| 306 | expect(h.sender.texts().at(-1)).toContain('Switched to Claude Code') | |
| 307 | h.fake.add({ chat: ME, text: 'three', fromMe: true }) | |
| 308 | await h.uplink.tick() | |
| 309 | expect(h.engine.runs).toHaveLength(1) | |
| 310 | }) | |
| 311 | ||
| 312 | it('says so rather than switching twice', async () => { | |
| 313 | const h = await harness() | |
| 314 | h.fake.add({ chat: ME, text: 'claude', fromMe: true }) | |
| 315 | await h.uplink.tick() | |
| 316 | expect(h.sender.texts().at(-1)).toContain('Already on Claude Code') | |
| 317 | }) | |
| 318 | ||
| 319 | /** | |
| 320 | * A task queued behind another runs on the agent it was sent to. Reading the | |
| 321 | * thread's engine at start time instead would silently move a waiting task | |
| 322 | * onto an agent its author never chose. | |
| 323 | */ | |
| 324 | it('runs a queued task on the agent it was sent to', async () => { | |
| 325 | const h = await harness() | |
| 326 | h.fake.add({ chat: ME, text: 'first', fromMe: true }) | |
| 327 | await h.uplink.tick() | |
| 328 | h.fake.add({ chat: ME, text: 'second', fromMe: true }) | |
| 329 | await h.uplink.tick() // queued behind the first | |
| 330 | ||
| 331 | h.fake.add({ chat: ME, text: 'codex', fromMe: true }) | |
| 332 | await h.uplink.tick() | |
| 333 | ||
| 334 | h.engine.finish(0, 'first done') | |
| 335 | await settle() | |
| 336 | ||
| 337 | // The queued one still went to Claude, and its row says so. | |
| 338 | expect(h.engine.runs).toHaveLength(2) | |
| 339 | expect(h.codex.runs).toHaveLength(0) | |
| 340 | expect(h.store.runs.get(2)?.engine).toBe('claude') | |
| 341 | }) | |
| 342 | ||
| 343 | it('will not switch to an agent that is not installed', async () => { | |
| 344 | const h = await harness() | |
| 345 | h.codex.probeResult = { ok: false, reason: 'missing', detail: 'Not found' } | |
| 346 | h.fake.add({ chat: ME, text: 'codex', fromMe: true }) | |
| 347 | await h.uplink.tick() | |
| 348 | ||
| 349 | expect(h.sender.texts().at(-1)).toContain('not installed') | |
| 350 | // Told how, because nobody switching from a phone can see the Mac. | |
| 351 | expect(h.sender.texts().at(-1)).toContain('bun install -g @openai/codex') | |
| 352 | ||
| 353 | // And the thread did not move. | |
| 354 | h.fake.add({ chat: ME, text: 'a task', fromMe: true }) | |
| 355 | await h.uplink.tick() | |
| 356 | expect(h.engine.runs).toHaveLength(1) | |
| 357 | }) | |
| 358 | ||
| 359 | it('says which agent an idle thread is on', async () => { | |
| 360 | const h = await harness() | |
| 361 | h.fake.add({ chat: ME, text: 'status', fromMe: true }) | |
| 362 | await h.uplink.tick() | |
| 363 | expect(h.sender.texts().at(-1)).toContain('Idle on Claude Code') | |
| 364 | }) | |
| 365 | ||
| 366 | it('names the agent the running task is on', async () => { | |
| 367 | const h = await harness() | |
| 368 | h.fake.add({ chat: ME, text: 'codex', fromMe: true }) | |
| 369 | await h.uplink.tick() | |
| 370 | h.fake.add({ chat: ME, text: 'a task', fromMe: true }) | |
| 371 | await h.uplink.tick() | |
| 372 | h.fake.add({ chat: ME, text: 'status', fromMe: true }) | |
| 373 | await h.uplink.tick() | |
| 374 | expect(h.sender.texts().at(-1)).toContain('Agent: Codex') | |
| 375 | }) | |
| 376 | ||
| 377 | it('offers both words in help', async () => { | |
| 378 | const h = await harness() | |
| 379 | h.fake.add({ chat: ME, text: 'help', fromMe: true }) | |
| 380 | await h.uplink.tick() | |
| 381 | expect(h.sender.texts().at(-1)).toContain('claude / codex') | |
| 382 | }) | |
| 383 | ||
| 247 | 384 | /** |
| 248 | 385 | * #16. The cutoff is right: a Mac waking up must not replay a day of texts. |
| 249 | 386 | * Saying nothing was not, because over a satellite link the send itself may |