diff --git a/docs/spec/clauses/03-providers-models-and-accounts.md b/docs/spec/clauses/03-providers-models-and-accounts.md index d304603d..b8d60cff 100644 --- a/docs/spec/clauses/03-providers-models-and-accounts.md +++ b/docs/spec/clauses/03-providers-models-and-accounts.md @@ -55,6 +55,9 @@ Boundaries: | [MUS-SPEC-03-722B](03-providers-models-and-accounts.md#mus-spec-03-722b) | Only the replay refusal is retried, and only once; any other 409 reaches the coding client unchanged with its request sent as is. | [`the bridge retries only the replay refusal, only once, and passes every other 409 through unchanged`](../../../packages/agent-app/tests/router-replay-722.test.ts) | | [MUS-SPEC-03-724A](03-providers-models-and-accounts.md#mus-spec-03-724a) | The first message on the new provider carries a summary of the earlier conversation ahead of the new text and says so in a notice; later messages continue the new provider conversation without it. | [`switching a chat to a model on another provider keeps the conversation: the first message there carries the earlier one`](../../../packages/agent-app/tests/provider-switch-context-724.test.ts) | | [MUS-SPEC-03-724B](03-providers-models-and-accounts.md#mus-spec-03-724b) | The next message continues the same provider thread with no summary added. | [`another model on the same provider account continues the same provider conversation without a summary`](../../../packages/agent-app/tests/provider-switch-context-724.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 @@ -500,3 +503,38 @@ Boundaries: - Environment: Node test runner; the real service with a fake provider adapter; no network. - Known gaps: Guard for existing behaviour, so it passes on main by design; it keeps the fix from adding a summary where none is needed. - 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 54ff6d6f..2eaddb2d 100644 --- a/packages/agent-app/CHANGELOG.md +++ b/packages/agent-app/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Muster Agent. Each `## ` section becomes the not ## Unreleased - Claude chats on your own router no longer reset to a summary when the router has lost the saved thinking for an earlier tool call. Muster resends that part of the history as plain text, so the chat keeps its full history and thread and carries on. Starting fresh is now a last resort. +- 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. - Switching a chat to a model on another provider or account no longer loses the conversation. The first message on the new model now carries a summary of the earlier messages, as forks and compaction already do, and the chat says so. ## 0.3.15 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'); +});