also looking at this
feat: let a text switch one thread between Claude and Codex
#52
16 files
+316
-27
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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 | |
| @@ -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 { |
| @@ -309,16 +321,18 @@ export class Uplink { | ||
| 309 | 321 | const conversation = await this.conversation(target) |
| 310 | 322 | const cwd = detectWorkdir(prompt, this.threadIsFresh(conversation) && conversation.cwd ? conversation.cwd : this.config.workdir) |
| 311 | 323 | |
| 324 | const engine = conversation.engine ?? this.config.engine | |
| 312 | 325 | const runId = await this.deps.store.createRun({ |
| 313 | 326 | chatGuid: target.chatGuid, |
| 314 | 327 | messageGuid: message.guid, |
| 315 | 328 | prompt, |
| 316 | 329 | cwd, |
| 317 | 330 | status: 'queued', |
| 331 | engine, | |
| 318 | 332 | }) |
| 319 | 333 | |
| 320 | 334 | const state = this.chat(target.chatGuid) |
| 321 | const job: Job = { runId, prompt, cwd, target } | |
| 335 | const job: Job = { runId, prompt, cwd, target, engine } | |
| 322 | 336 | if (state.active) { |
| 323 | 337 | state.queue.push(job) |
| 324 | 338 | await this.reply(target, `Queued behind the current task (${state.queue.length} waiting). Text "status" or "stop".`) |
| @@ -328,19 +342,27 @@ export class Uplink { | ||
| 328 | 342 | await this.begin(state, job) |
| 329 | 343 | } |
| 330 | 344 | |
| 345 | /** The engine behind an id, falling back to the installation's one. */ | |
| 346 | private engineOf(id: EngineId): Engine { | |
| 347 | return this.deps.engineFor?.(id) ?? this.deps.engine | |
| 348 | } | |
| 349 | ||
| 331 | 350 | private threadIsFresh(conversation: ConversationRecord): boolean { |
| 332 | 351 | return conversation.lastActiveAt !== null && this.now() - conversation.lastActiveAt < this.config.sessionIdleMs |
| 333 | 352 | } |
| 334 | 353 | |
| 335 | 354 | private async begin(state: ChatState, job: Job): Promise<void> { |
| 336 | 355 | const conversation = await this.conversation(job.target) |
| 337 | const resume = conversation.sessionId && conversation.cwd === job.cwd && this.threadIsFresh(conversation) | |
| 356 | // The engine has to agree as well: a session id belongs to the agent that | |
| 357 | // made it, and handing a Claude one to Codex resumes nothing. | |
| 358 | const sameEngine = (conversation.engine ?? this.config.engine) === job.engine | |
| 359 | const resume = conversation.sessionId && conversation.cwd === job.cwd && sameEngine && this.threadIsFresh(conversation) | |
| 338 | 360 | ? conversation.sessionId |
| 339 | 361 | : null |
| 340 | 362 | |
| 341 | 363 | await this.deps.store.updateRun(job.runId, { status: 'running', startedAt: this.now() }) |
| 342 | 364 | |
| 343 | const run = this.deps.engine.run({ | |
| 365 | const run = this.engineOf(job.engine).run({ | |
| 344 | 366 | prompt: job.prompt, |
| 345 | 367 | cwd: job.cwd, |
| 346 | 368 | sessionId: resume, |
| @@ -407,7 +429,7 @@ export class Uplink { | ||
| 407 | 429 | // Naming the wrong CLI, or a .env the downloadable app does not have, |
| 408 | 430 | // sends someone at the Mac to fix something that is not broken. The |
| 409 | 431 | // engine carries its own remedy. |
| 410 | const engine = this.deps.engine | |
| 432 | const engine = this.engineOf(active.engine) | |
| 411 | 433 | const text = result.authFailure |
| 412 | 434 | ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.` |
| 413 | 435 | : result.ok ? result.text : `That failed: ${result.text}` |
| @@ -446,13 +468,19 @@ export class Uplink { | ||
| 446 | 468 | return |
| 447 | 469 | |
| 448 | 470 | case 'status': { |
| 471 | // A thread that can switch has to be able to say what it is on. | |
| 472 | const conversation = await this.conversation(target) | |
| 473 | const thread = this.engineOf(conversation.engine ?? this.config.engine).label | |
| 449 | 474 | if (!state.active) { |
| 450 | await this.reply(target, 'Idle. Text me a question or a task.') | |
| 475 | await this.reply(target, `Idle on ${thread}. Text me a question or a task.`) | |
| 451 | 476 | return |
| 452 | 477 | } |
| 453 | 478 | const active = state.active |
| 454 | 479 | const lines = [ |
| 455 | 480 | `Working ${formatDuration(this.now() - active.startedAt)} on: ${truncate(active.prompt, 80)}`, |
| 481 | // The running task's engine, which is not always the thread's: it | |
| 482 | // keeps whatever it was sent to. | |
| 483 | `Agent: ${this.engineOf(active.engine).label}`, | |
| 456 | 484 | active.lastActivity ? `Latest: ${active.lastActivity}` : 'Thinking.', |
| 457 | 485 | ] |
| 458 | 486 | if (state.queue.length > 0) |
| @@ -472,6 +500,35 @@ export class Uplink { | ||
| 472 | 500 | return |
| 473 | 501 | } |
| 474 | 502 | |
| 503 | case 'claude': | |
| 504 | case 'codex': { | |
| 505 | const conversation = await this.conversation(target) | |
| 506 | const engine = this.engineOf(command) | |
| 507 | if ((conversation.engine ?? this.config.engine) === command) { | |
| 508 | await this.reply(target, `Already on ${engine.label}.`) | |
| 509 | return | |
| 510 | } | |
| 511 | ||
| 512 | // Asked now rather than when the next text fails. Someone switching | |
| 513 | // from a phone cannot see that the CLI is missing, and finding out on | |
| 514 | // their real task costs them the task. | |
| 515 | const probe = await engine.probe() | |
| 516 | if (!probe.ok) { | |
| 517 | await this.reply(target, probe.reason === 'missing' | |
| 518 | ? `${engine.label} is not installed on this Mac. Install it there with: ${engine.install.command}` | |
| 519 | : `${engine.label} is not ready: ${probe.detail}`) | |
| 520 | return | |
| 521 | } | |
| 522 | ||
| 523 | // The session id goes with it. It belongs to the agent that made it, | |
| 524 | // and the other one cannot resume it, so this starts a fresh | |
| 525 | // conversation the way "new" does. The working directory stays: it is | |
| 526 | // not the agent's, it is the thread's. | |
| 527 | await this.deps.store.saveConversation({ ...conversation, engine: command, sessionId: null }) | |
| 528 | await this.reply(target, `Switched to ${engine.label}. Starting a fresh conversation, because a session cannot move between agents.`) | |
| 529 | return | |
| 530 | } | |
| 531 | ||
| 475 | 532 | case 'more': { |
| 476 | 533 | const conversation = await this.conversation(target) |
| 477 | 534 | if (!conversation.moreText) { |
| @@ -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 | |
| @@ -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()) |
| @@ -243,4 +258,127 @@ describe('Uplink', () => { | ||
| 243 | 258 | expect(h.sender.texts()[0]).toContain('claude setup-token') |
| 244 | 259 | expect(h.store.runs.get(1)?.status).toBe('failed') |
| 245 | 260 | }) |
| 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 | }) | |
| 246 | 383 | }) |
| 384 | ||