From 0040e3b90ba03553e6ee7163d8afeea22bc65ce3 Mon Sep 17 00:00:00 2001 From: Dkm0315 <127371606+Dkm0315@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:30:56 +0530 Subject: [PATCH] Run coding-client chats in Muster's own home (#720) --- .../03-providers-models-and-accounts.md | 38 ++++ packages/agent-app/CHANGELOG.md | 4 + packages/agent-app/src/runtime/codex-home.ts | 82 +++++++++ .../src/runtime/provider-instances.ts | 10 +- .../agent-app/src/runtime/provider-usage.ts | 22 ++- packages/agent-app/src/runtime/provider.ts | 3 + .../tests/own-codex-home-720.test.ts | 165 ++++++++++++++++++ 7 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 packages/agent-app/src/runtime/codex-home.ts create mode 100644 packages/agent-app/tests/own-codex-home-720.test.ts diff --git a/docs/spec/clauses/03-providers-models-and-accounts.md b/docs/spec/clauses/03-providers-models-and-accounts.md index ffa680b1..7ddb6982 100644 --- a/docs/spec/clauses/03-providers-models-and-accounts.md +++ b/docs/spec/clauses/03-providers-models-and-accounts.md @@ -51,6 +51,9 @@ Boundaries: | [MUS-SPEC-03-716A](03-providers-models-and-accounts.md#mus-spec-03-716a) | A resume refused with "replay state expired or is unavailable" before the turn starts is retried once on a fresh thread: the message reaches the provider twice at most, the retry resumes nothing and starts with a summary of the earlier messages, one notice says a fresh conversation started, and the chat ends completed on the new thread. | [`a resumed router thread whose replay state expired starts one fresh conversation with a summary`](../../../packages/agent-app/tests/replay-expired-716.test.ts) | | [MUS-SPEC-03-716B](03-providers-models-and-accounts.md#mus-spec-03-716b) | If the fresh conversation is refused with the same error there is no third attempt and the error is shown through the usual recovery notice. | [`when the fresh conversation is refused with the same 409 there is no third attempt and the error is shown`](../../../packages/agent-app/tests/replay-expired-716.test.ts) | | [MUS-SPEC-03-716C](03-providers-models-and-accounts.md#mus-spec-03-716c) | A 409 with other text, or the same text after the turn produced output, does not start a fresh conversation. | [`a 409 with other text, or after the turn produced output, does not start a fresh conversation`](../../../packages/agent-app/tests/replay-expired-716.test.ts) | +| [MUS-SPEC-03-720A](03-providers-models-and-accounts.md#mus-spec-03-720a) | The chat runs in Muster's own coding-client home under its data folder: no thread or thread index is written to the person's home, the person's profile is untouched, and follow-ups continue the same thread. | [`a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) | +| [MUS-SPEC-03-720B](03-providers-models-and-accounts.md#mus-spec-03-720b) | A chat started before the change continues on its own thread with its earlier messages, and the person's copy of that thread is not written to again. | [`a chat started before the change continues its own thread with its history, without writing to the person’s home again`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) | +| [MUS-SPEC-03-720C](03-providers-models-and-accounts.md#mus-spec-03-720c) | Usage limits recorded in the person's home by their other coding apps still show for that account, the newest reading winning. | [`usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) | ### Configured routers @@ -448,3 +451,38 @@ Boundaries: - Environment: Node test runner; the real service and provider adapter with a fake Codex client; no network. - Known gaps: None known. - Evidence: UNVERIFIED + +### Muster's own coding-client home + + +#### MUS-SPEC-03-720A: A new chat runs in Muster's own home + +- Precondition: The person has a coding-client home with a sign-in or profile (here a router profile with an auth helper). +- Action: Start a chat on that profile, send a message, then a follow-up. +- Expected result: The run reaches the router through the person's profile; the thread is stored under `/codex-home//sessions`, the person's home gets no rollout and no thread index, the profile file is unchanged, and the follow-up continues the same thread. +- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration` +- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders. +- Known gaps: A ChatGPT sign-in kept only in the Keychain is tied to the home's path, so that route keeps the person's home; Windows without symlink rights copies shared files instead of linking them. +- Evidence: UNVERIFIED + + +#### MUS-SPEC-03-720B: A chat from before the change keeps its thread + +- Precondition: A chat ran on an earlier build, so its thread is in the person's home. +- Action: Open it on this build and send a message that depends on the earlier one. +- Expected result: The chat keeps its provider thread, the model sees the earlier message, the person's copy of the thread does not grow, and no new thread appears there. +- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a chat started before the change continues its own thread with its history, without writing to the person’s home again` +- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders. +- Known gaps: None known. +- Evidence: UNVERIFIED + + +#### MUS-SPEC-03-720C: Usage limits still show + +- Precondition: A chat has run in Muster's own home, and another coding app has since recorded a newer rate-limit reading for the same account in the person's home. +- Action: Read the usage for that account. +- Expected result: The newer reading from the person's home is shown. +- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home` +- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders. +- Known gaps: None known. +- Evidence: UNVERIFIED diff --git a/packages/agent-app/CHANGELOG.md b/packages/agent-app/CHANGELOG.md index e9d98bda..d4f37cfa 100644 --- a/packages/agent-app/CHANGELOG.md +++ b/packages/agent-app/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to Muster Agent. Each `## ` section becomes the notes of the `agent-v` GitHub Release (`.github/workflows/agent-app-release.yml`). +## Unreleased + +- Chats on your coding-client sign-ins and profiles no longer show up in your other coding apps. Muster now keeps its own threads in its data folder while sharing your sign-in, profiles, skills and plugins; existing chats continue where they left off. + ## 0.3.15 Claude chats on your own router recover on their own when an older conversation can no longer be continued, and notification toasts move to the bottom-right corner, clear of your messages. diff --git a/packages/agent-app/src/runtime/codex-home.ts b/packages/agent-app/src/runtime/codex-home.ts new file mode 100644 index 00000000..a2ddf67b --- /dev/null +++ b/packages/agent-app/src/runtime/codex-home.ts @@ -0,0 +1,82 @@ +/** + * #720: Muster runs the coding client with its own home, so the threads it creates never show up in the person's other + * coding apps that read the same home. The person's home stays the source of truth for everything the client should + * share: sign-in, configuration, profiles, model catalogs, skills, plugins, rules and helpers are linked in. Threads, + * the thread index, logs, history and memories stay private to Muster's home. + */ +import {createHash} from 'node:crypto'; +import {copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, statSync, symlinkSync, unlinkSync, type Dirent} from 'node:fs'; +import {join, resolve} from 'node:path'; + +const SHARED = new Set(['auth.json', 'config.toml', 'models_cache.json', 'model-catalogs', 'skills', 'plugins', 'rules', 'prompts', + 'AGENTS.md', 'AGENTS.override.md', 'helpers', 'computer-use', 'node_repl', 'packages', 'vendor_imports']); +/** Whether an entry of the person's home is shared with Muster's home (profiles are `.config.toml`). */ +export const sharedCodexEntry = (name: string): boolean => SHARED.has(name) || /^[A-Za-z0-9_.-]{1,120}\.config\.toml$/.test(name); + +/** Muster's own home for one of the person's homes (the default one or an extra account). */ +export function musterCodexHome(dataDir: string, sourceHome: string): string { + return join(dataDir, 'codex-home', createHash('sha256').update(resolve(sourceHome)).digest('hex').slice(0, 12)); +} + +/** Links the shared entries of the person's home into Muster's home. Run before each launch, so profiles added since + * appear and a link the client replaced with a file (a save that renamed over it) is folded back: the newer copy + * goes to the person's home and the link is restored. Where links can't be made (Windows without the privilege), + * directories use junctions and files are copied both ways by age. */ +export function prepareMusterCodexHome(sourceHome: string, home: string): void { + mkdirSync(home, {recursive: true, mode: 0o700}); + let entries: Dirent[] = []; + try { entries = readdirSync(sourceHome, {withFileTypes: true}); } catch { return; } + for (const entry of entries) { + if (!sharedCodexEntry(entry.name)) continue; + const target = join(sourceHome, entry.name), link = join(home, entry.name); + let directory = entry.isDirectory(); + try { if (entry.isSymbolicLink()) directory = statSync(target).isDirectory(); } catch { continue; } + let current: ReturnType | undefined; + try { current = lstatSync(link); } catch { current = undefined; } + try { + if (current?.isSymbolicLink()) { if (readlinkSync(link) === target) continue; unlinkSync(link); } + else if (current?.isFile()) { + if (current.mtimeMs > statSync(target).mtimeMs) copyFileSync(link, target); + unlinkSync(link); + } else if (current) continue; + try { symlinkSync(target, link, directory ? 'junction' : 'file'); } + catch { if (!directory) copyFileSync(target, link); } + } catch { /* this entry stays as it is; the client reports what it misses */ } + } +} + +const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const adopted = new Set(); +const subdirs = (dir: string) => { try { return readdirSync(dir, {withFileTypes: true}).filter(entry => entry.isDirectory()).map(entry => entry.name).sort().reverse(); } catch { return []; } }; +const rolloutIn = (dir: string, suffix: string) => { try { return readdirSync(dir).find(name => name.startsWith('rollout-') && name.endsWith(suffix)); } catch { return undefined; } }; + +/** Where a thread's rollout lives under a home: `sessions/YYYY/MM/DD/`, or flat in `archived_sessions/`. */ +function findRollout(home: string, threadId: string): {path: string; dated: string[]} | undefined { + const suffix = `-${threadId}.jsonl`, sessions = join(home, 'sessions'); + for (const year of subdirs(sessions)) for (const month of subdirs(join(sessions, year))) for (const day of subdirs(join(sessions, year, month))) { + const name = rolloutIn(join(sessions, year, month, day), suffix); + if (name) return {path: join(sessions, year, month, day, name), dated: [year, month, day, name]}; + } + const archived = rolloutIn(join(home, 'archived_sessions'), suffix); + const date = archived?.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T/); + return archived && date ? {path: join(home, 'archived_sessions', archived), dated: [date[1]!, date[2]!, date[3]!, archived]} : undefined; +} + +/** A chat started before #720 has its thread in the person's home: copy it into Muster's home once, so the chat + * continues there with its whole history and the person's copy is never written to again. */ +export function adoptCodexThread(sourceHome: string, home: string, threadId: string | undefined): void { + if (!threadId || !THREAD_ID.test(threadId)) return; + const key = `${home}\0${threadId}`; + if (adopted.has(key)) return; + if (!findRollout(home, threadId)) { + const found = findRollout(sourceHome, threadId); + if (found) try { + const [year, month, day, name] = found.dated as [string, string, string, string]; + const folder = join(home, 'sessions', year, month, day); + mkdirSync(folder, {recursive: true, mode: 0o700}); + if (!existsSync(join(folder, name))) copyFileSync(found.path, join(folder, name)); + } catch { return; } + } + if (adopted.size >= 4096) adopted.clear(); + adopted.add(key); +} diff --git a/packages/agent-app/src/runtime/provider-instances.ts b/packages/agent-app/src/runtime/provider-instances.ts index 3a90665f..1cfc5cb7 100644 --- a/packages/agent-app/src/runtime/provider-instances.ts +++ b/packages/agent-app/src/runtime/provider-instances.ts @@ -1,3 +1,4 @@ +import {musterCodexHome} from './codex-home.ts'; import {createRequire} from 'node:module'; import {createHash} from 'node:crypto'; import * as nodeFs from 'node:fs'; @@ -511,12 +512,15 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc const label=duplicate?`${names[index]} (${route.profile})`:names[index]!; const name=account?`${label} · ${account.label??email??`account ${suffix.slice(1,7)}`}`:label; const catalogPath=route.catalogPath&&isAbsolute(route.catalogPath)?route.catalogPath:undefined; - const childEnv:Record={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:codexHome,...(cli?{MUSTER_CODEX_COMMAND:cli}:{}), + // #720: chats run in Muster's own home (shared sign-in and configuration, private threads). A sign-in kept outside + // auth.json (the Keychain) is tied to the home's path, so that route keeps the person's home. + const ownHome=registeredDataDir&&env.MUSTER_SHARED_CODEX_HOME!=='1'&&(route.kind!=='chatgpt'||Boolean(auth))?musterCodexHome(registeredDataDir,codexHome):undefined; + const childEnv:Record={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:ownHome??codexHome,...(ownHome?{MUSTER_CODEX_SOURCE_HOME:codexHome}:{}),...(cli?{MUSTER_CODEX_COMMAND:cli}:{}), ...(route.profile?{MUSTER_CODEX_PROFILE:route.profile}:{MUSTER_CODEX_PROVIDER:route.modelProvider,...(catalogPath?{MUSTER_CODEX_CATALOG:catalogPath}:{})})}; const codex={modelProvider:route.modelProvider,kind:route.kind,...(route.profile?{profile:route.profile}:{}),...(account?{account:suffix.slice(1)}:{})} satisfies NonNullable; const source=route.profile?`Codex profile ${route.profile}.config.toml${account?` in ${account.codexHome}`:''}`:route.kind==='chatgpt'?`Codex sign-in${account?` at ${account.codexHome}`:''}`:`[model_providers.${route.modelProvider}] in Codex config.toml${account?` at ${account.codexHome}`:''}`; const base={id,name,driver:'codex-app-server',codex,identityMasked:route.kind==='gateway'?'Gateway · account hidden':email??'ChatGPT account · hidden',models:[],available:false,source,...(route.baseUrl?{endpoint:route.baseUrl}:{})} satisfies ProviderInfo; - const sessionsRoot=join(codexHome,'sessions'); + const sessionsRoot=join(ownHome??codexHome,'sessions'); const fail=(error:string,status:'configured'|'error'='configured'):ProviderInstance=>({info:{...base,status,error,detail:`${error} No provider fallback will be used. ${mcpDetail(mcp)}`},command,env:childEnv,sessionsRoot}); if(route.invalid)return fail(`${route.profile}.config.toml did not validate: ${route.invalid}`,'error'); try {fs.accessSync(command,constants.X_OK);} catch {return fail('Muster’s bundled Codex launcher is missing. Reinstall Muster.','error');} @@ -598,5 +602,5 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc /** The Codex route behind a provider id, from the current instance list; undefined for non-Codex routes. */ export function codexRouteFor(id:string,options:ProviderInstanceOptions={}):{codex:NonNullable;codexHome:string;sessionsRoot:string;instance:ProviderInstance}|undefined { const instance=configuredProviderInstances(options).find(row=>row.info.id===id&&row.info.codex); - return instance?{codex:instance.info.codex!,codexHome:instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined; + return instance?{codex:instance.info.codex!,codexHome:instance.env.MUSTER_CODEX_SOURCE_HOME??instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined; } diff --git a/packages/agent-app/src/runtime/provider-usage.ts b/packages/agent-app/src/runtime/provider-usage.ts index 725205d0..5c700d79 100644 --- a/packages/agent-app/src/runtime/provider-usage.ts +++ b/packages/agent-app/src/runtime/provider-usage.ts @@ -5,8 +5,8 @@ * - session-log: the newest `rate_limits` line in a Codex rollout file under sessionsRoot. * Percentages are clamped to 0-100; anything malformed is ignored, never shown as 0%. */ -import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +import { closeSync, openSync, readSync, readdirSync, readlinkSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { ProviderUsage, ProviderUsageWindow } from '../shared/domains/providers-protocol.ts'; const record = (value: unknown): Record | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; @@ -73,6 +73,24 @@ export function recentRollouts(sessionsRoot: string, limit = 12): string[] { } /** The newest rate-limit snapshot per model provider id (`session_meta.model_provider`) across recent rollouts. */ export function sessionRateLimits(sessionsRoot: string): Map { + const found = rootRateLimits(sessionsRoot); + // #720: Muster's own home links its sign-in to the person's home; other apps' recent rollouts there carry the same + // account's limits, so the newest reading from either side wins. + const source = linkedSourceHome(dirname(sessionsRoot)); + if (source) for (const [provider, usage] of rootRateLimits(join(source, 'sessions'))) { + const mine = found.get(provider); + if (!mine || usage.updatedAt > mine.updatedAt) found.set(provider, usage); + } + return found; +} +/** The person's home behind Muster's own home, read from the link Muster keeps for its sign-in or configuration. */ +function linkedSourceHome(home: string): string | undefined { + for (const name of ['auth.json', 'config.toml']) { + try { const target = readlinkSync(join(home, name)); const path = isAbsolute(target) ? target : resolve(home, target); return dirname(path); } catch { /* not a link */ } + } + return undefined; +} +function rootRateLimits(sessionsRoot: string): Map { const found = new Map(); for (const file of recentRollouts(sessionsRoot)) { let head = '', tail = ''; diff --git a/packages/agent-app/src/runtime/provider.ts b/packages/agent-app/src/runtime/provider.ts index 9475fc88..31c816ba 100644 --- a/packages/agent-app/src/runtime/provider.ts +++ b/packages/agent-app/src/runtime/provider.ts @@ -23,6 +23,7 @@ import {ASK_USER_METHOD, ASK_USER_RULE} from './ask-user.ts'; import type {McpServerSpec} from './adapters/types.ts'; import type {RunnableAdapter} from './adapters/types.ts'; import {CLAUDE_LANE_DRIVER} from '../shared/provider-lanes.ts'; +import {adoptCodexThread, prepareMusterCodexHome} from './codex-home.ts'; import {claudeLaneAllowed, LANE_DROPPED_SERVERS, withoutUnavailableToolNotes} from './claude-lane.ts'; import {SANDBOX_MCP_SERVER} from '../shared/sandbox-routes.ts'; import {routeSandboxNote} from './sandbox-note.ts'; @@ -415,6 +416,8 @@ export function createProviderAdapter(options: { core?: CoreClient; available?: const developerInstructions = runInstructions(input.chat.mode, input.developerInstructions, true, providerAccessPolicy(input.chat).permissionMode); const split = splitSecretOverrides(input.configOverrides); const extraOverrides = Object.entries(split.args).filter(([name]) => /^[A-Za-z0-9_.-]{1,128}$/.test(name) && !/^(sandbox|approval_policy)/.test(name)).map(([name, value]) => `${name}=${JSON.stringify(value)}`); + // #720: link the person's shared configuration into Muster's own home, and bring over a thread from before. + if (route.env.MUSTER_CODEX_SOURCE_HOME && route.env.CODEX_HOME) { prepareMusterCodexHome(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME); adoptCodexThread(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME, resumeThread); } const launch = { cwd: input.cwd, command: route.command, model, reasoning, env: { MUSTER_PROVIDER_NODE: node.node, ...node.env, ...route.env }, transportOwner: session.owner, cacheKey: session.key, diff --git a/packages/agent-app/tests/own-codex-home-720.test.ts b/packages/agent-app/tests/own-codex-home-720.test.ts new file mode 100644 index 00000000..c2e8ec3a --- /dev/null +++ b/packages/agent-app/tests/own-codex-home-720.test.ts @@ -0,0 +1,165 @@ +// Muster's coding-client chats must not appear in the person's other coding apps (#720): Muster runs the client +// with its own home under its data folder, sharing sign-in, configuration, profiles and skills with the person's +// home but keeping its own threads. Chats made before this change still continue on their own thread. +import assert from 'node:assert/strict'; +import {test, type TestContext} from 'node:test'; +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs'; +import {mkdir, mkdtemp, writeFile, chmod} from 'node:fs/promises'; +import {join, resolve} from 'node:path'; +import {tmpdir} from 'node:os'; +import {execFileSync} from 'node:child_process'; +import {createServer, type IncomingMessage, type ServerResponse} from 'node:http'; +import type {AddressInfo} from 'node:net'; +import {createAgentService} from '../src/runtime/service.ts'; +import {createProviderAdapter, type CoreClient} from '../src/runtime/provider.ts'; +import {configuredProviderInstances, invalidateProviderInstances} from '../src/runtime/provider-instances.ts'; +import {sessionRateLimits} from '../src/runtime/provider-usage.ts'; +import {removeTmp} from './fixtures/tmp-dir.ts'; +import {codexCatalog} from './fixtures/mock-openai-router.ts'; + +const appDir = resolve(import.meta.dirname, '..'); +const coreBundle = join(appDir, 'dist/runtime/core-client.cjs'); +const cli = process.env.MUSTER_CODEX_COMMAND || (() => {try {return execFileSync('/bin/sh', ['-c', 'command -v codex'], {encoding: 'utf8'}).trim();} catch {return undefined;}})(); +const skip = !cli ? 'coding client unavailable' : !existsSync(coreBundle) ? 'build core-client.cjs first' : false; +if (skip && process.env.MUSTER_REQUIRE_CODEX_E2E === '1') throw new Error(String(skip)); +const token = 'sk-fixture-720-not-a-real-key'; +const MODEL = 'fixture-model-720'; + +/** Every rollout file under a coding-client home's sessions folder. */ +function rollouts(home: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { for (const entry of existsSync(dir) ? readdirSync(dir, {withFileTypes: true}) : []) { const path = join(dir, entry.name); if (entry.isDirectory()) walk(path); else if (entry.name.endsWith('.jsonl')) out.push(path); } }; + walk(join(home, 'sessions')); + return out; +} + +async function router(t: TestContext) { + const inputs: string[][] = []; + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const path = (req.url ?? '').split('?')[0]!; + if (req.headers.authorization !== `Bearer ${token}`) { res.writeHead(401); res.end(); return; } + if (req.method === 'GET' && path === '/v1/models') { res.writeHead(200, {'content-type': 'application/json'}); res.end(JSON.stringify({object: 'list', data: [{id: MODEL, object: 'model'}]})); return; } + if (req.method !== 'POST' || path !== '/v1/responses') { res.writeHead(404); res.end(); return; } + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {input: Array<{type?: string; role?: string; content?: Array<{text?: string}>}>}; + inputs.push(body.input.filter(item => item.type === 'message' && item.role === 'user').flatMap(item => (item.content ?? []).map(part => part.text ?? ''))); + const item = {type: 'message', id: `msg_${inputs.length}`, role: 'assistant', status: 'completed', content: [{type: 'output_text', text: `answer ${inputs.length}`, annotations: []}]}; + res.writeHead(200, {'content-type': 'text/event-stream'}); + for (const [event, data] of [ + ['response.created', {type: 'response.created', response: {id: `resp_${inputs.length}`, object: 'response', status: 'in_progress', model: MODEL}}], + ['response.output_item.done', {type: 'response.output_item.done', output_index: 0, item}], + ['response.completed', {type: 'response.completed', response: {id: `resp_${inputs.length}`, object: 'response', status: 'completed', model: MODEL, output: [item], + usage: {input_tokens: 3, input_tokens_details: {cached_tokens: 0}, output_tokens: 4, output_tokens_details: {reasoning_tokens: 0}, total_tokens: 7}}}], + ] as const) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + res.end(); + }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + t.after(() => new Promise(done => { server.closeAllConnections(); server.close(() => done()); })); + return {base: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`, inputs}; +} + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'muster-home-720-')); + t.after(() => removeTmp(root)); + const mock = await router(t); + const home = join(root, 'home'), codex = join(home, '.codex'), cwd = join(root, 'work'), dataDir = join(root, 'data'); + await mkdir(codex, {recursive: true}); await mkdir(cwd); + const helper = join(root, 'token.sh'); + await writeFile(helper, `#!/bin/sh\nprintf '%s' '${token}'\n`); await chmod(helper, 0o700); + await writeFile(join(codex, 'config.toml'), ''); + const catalog = join(root, 'catalog.json'); + await writeFile(catalog, codexCatalog([MODEL])); + const profile = `model_provider="fixture720"\nmodel_catalog_json=${JSON.stringify(catalog)}\n[model_providers.fixture720]\nname="Fixture router"\nbase_url=${JSON.stringify(mock.base)}\nwire_api="responses"\nrequest_max_retries=0\nstream_max_retries=0\n[model_providers.fixture720.auth]\ncommand=${JSON.stringify(helper)}\nargs=[]\n`; + await writeFile(join(codex, 'fixture.config.toml'), profile); + const core = createRequire(join(appDir, 'fixture.cjs'))(coreBundle) as CoreClient; + /** One app session: the service and coding client as Muster runs them. `shared` simulates a build from before #720. */ + async function open(shared = false) { + const env = {...process.env, HOME: home, CODEX_HOME: codex, MUSTER_CODEX_COMMAND: cli!, MUSTER_PROVIDER_NODE: process.execPath, ...(shared ? {MUSTER_SHARED_CODEX_HOME: '1'} : {})}; + delete (env as Record).OPENAI_API_KEY; + if (!shared) delete (env as Record).MUSTER_SHARED_CODEX_HOME; + invalidateProviderInstances(); + const provider = createProviderAdapter({core, instances: () => configuredProviderInstances({directory: join(appDir, 'dist/runtime'), home, env})}); + const service = createAgentService({dataDir, provider, onEvent() {}}); + t.after(() => service.dispose()); + return service; + } + async function send(service: Awaited>, id: string, text: string) { + await service.invoke('chat.send', {id, text, requestId: randomUUID()}); + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + const row = (await service.invoke('app.snapshot', undefined)).chats.find(c => c.id === id)!; + if (['completed', 'failed', 'interrupted'].includes(row.status)) return row; + await new Promise(done => setTimeout(done, 20)); + } + assert.fail('chat did not settle within 45 seconds'); + } + async function newChat(service: Awaited>) { + const folder = await service.invoke('folder.add', {path: cwd}); + const created = await service.invoke('chat.create', {folderId: folder.id}); + await service.invoke('chat.selectProvider', {id: created.id, providerId: 'fixture720', model: MODEL}); + return created.id; + } + return {mock, codex, dataDir, profile, open, send, newChat}; +} + +// Spec: MUS-SPEC-03-720A (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720a) +test('a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration', {skip, timeout: 120_000}, async t => { + const {mock, codex, dataDir, profile, open, send, newChat} = await fixture(t); + const service = await open(); + const id = await newChat(service); + const row = await send(service, id, 'hello from a new chat'); + assert.equal(row.status, 'completed', row.error ?? ''); + assert.ok(mock.inputs.length >= 1, 'the run reached the router through the person’s profile'); + assert.deepEqual(rollouts(codex), [], 'no thread is written to the person’s home, so other coding apps never list it'); + assert.ok(!readdirSync(codex).some(name => /^state_\d+\.sqlite$/.test(name)), 'no thread index is created in the person’s home'); + const own = join(dataDir, 'codex-home'); + assert.ok(existsSync(own), 'Muster keeps its own coding-client home in its data folder'); + const ownRollouts = readdirSync(own).flatMap(entry => rollouts(join(own, entry))); + assert.equal(ownRollouts.length, 1, 'the thread lives in Muster’s own home'); + assert.ok(readFileSync(ownRollouts[0]!, 'utf8').includes(row.providerThreadId!), 'it is this chat’s thread'); + assert.equal(readFileSync(join(codex, 'fixture.config.toml'), 'utf8'), profile, 'the person’s profile is untouched'); + const second = await send(service, id, 'and a follow-up'); + assert.equal(second.status, 'completed', second.error ?? ''); + assert.equal(second.providerThreadId, row.providerThreadId, 'follow-ups continue the same thread'); +}); + +// Spec: MUS-SPEC-03-720B (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720b) +test('a chat started before the change continues its own thread with its history, without writing to the person’s home again', {skip, timeout: 120_000}, async t => { + const {mock, codex, open, send, newChat} = await fixture(t); + const before = await open(true); + const id = await newChat(before); + const first = await send(before, id, 'remember the word MARIGOLD'); + assert.equal(first.status, 'completed', first.error ?? ''); + const [old] = rollouts(codex); + assert.ok(old, 'the earlier build wrote this chat’s thread into the person’s home'); + await before.dispose(); + const size = statSync(old).size; + + const after = await open(); + const next = await send(after, id, 'which word did I ask you to remember?'); + assert.equal(next.status, 'completed', next.error ?? ''); + assert.equal(next.providerThreadId, first.providerThreadId, 'the chat keeps its thread'); + assert.ok(mock.inputs.at(-1)!.some(text => text.includes('remember the word MARIGOLD')), 'the earlier message is still in the history the model sees'); + assert.equal(statSync(old).size, size, 'the person’s copy of the thread is not written to again'); + assert.equal(rollouts(codex).length, 1, 'no new thread appears in the person’s home'); +}); + +// Spec: MUS-SPEC-03-720C (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720c) +test('usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home', {skip, timeout: 120_000}, async t => { + const {codex, dataDir, open, send, newChat} = await fixture(t); + const service = await open(); + const id = await newChat(service); + assert.equal((await send(service, id, 'hello')).status, 'completed'); + const [own] = readdirSync(join(dataDir, 'codex-home')); + const ownSessions = join(dataDir, 'codex-home', own!, 'sessions'); + const day = join(codex, 'sessions', '2099', '01', '01'); + await mkdir(day, {recursive: true}); + await writeFile(join(day, `rollout-2099-01-01T00-00-00-${randomUUID()}.jsonl`), [ + JSON.stringify({type: 'session_meta', payload: {model_provider: 'fixture720'}}), + JSON.stringify({timestamp: '2099-01-01T00:00:00Z', type: 'event_msg', payload: {type: 'token_count', rate_limits: {primary: {used_percent: 64, window_minutes: 300, resets_in_seconds: 60}}}}), + ].join('\n')); + assert.equal(sessionRateLimits(ownSessions).get('fixture720')?.primary?.usedPercent, 64, 'the newest reading from the person’s home is used'); +});