From 8422512841473125480a6725cf3c4698f6332eeb Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 18:21:48 -0700 Subject: [PATCH 1/5] fix: normalize blank subagent selectors HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05a5f-b382-78e4-8082-ead4ae940a0b --- packages/fold-core/src/Subagents/Schemas.ts | 25 +++++++++----- packages/fold-core/src/Tools/Contracts.ts | 6 ++-- .../Subagents/SubagentToolWire.vi.test.ts | 33 ++++++++++++++++--- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index ac7f8bb..56f5f7d 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -101,6 +101,9 @@ export type SubagentToolWireParameters = { readonly fork?: boolean } +const nonEmptyOptionalString = (value: string | undefined): string | undefined => + value === undefined || value.trim().length === 0 ? undefined : value + /** * Parse the tool's flat wire parameters into exactly one {@link SubagentCommand}. The wire shape stays * flat because schema unions confuse models (D21 ruling); this is the single boundary where it becomes @@ -111,8 +114,14 @@ export const parseSubagentCommand = ( params: SubagentToolWireParameters, ): Effect.Effect => Effect.gen(function* () { - const skill = params.skill ?? null - const selectorCount = [params.agent !== undefined, params.agent_id !== undefined, params.fork === true].filter( + // OpenAI-compatible providers expose optional fields as required nullable fields. Some models, + // notably Grok, use empty strings and false instead of null for inactive selectors. Normalize + // those placeholders at the semantic parsing boundary so they do not count as active selectors. + const description = nonEmptyOptionalString(params.description) + const skill = nonEmptyOptionalString(params.skill) ?? null + const agent = nonEmptyOptionalString(params.agent) + const agentIdRef = nonEmptyOptionalString(params.agent_id) + const selectorCount = [agent !== undefined, agentIdRef !== undefined, params.fork === true].filter( Boolean, ).length @@ -124,28 +133,28 @@ export const parseSubagentCommand = ( }) } - if (params.agent !== undefined) { + if (agent !== undefined) { return DispatchSubagentCommand.make({ - agent: params.agent, - ...(params.description === undefined ? {} : { description: params.description }), + agent, + ...(description === undefined ? {} : { description }), prompt: params.prompt, skill, }) } if (params.fork === true) { return ForkSubagentCommand.make({ - ...(params.description === undefined ? {} : { description: params.description }), + ...(description === undefined ? {} : { description }), prompt: params.prompt, skill, }) } - const agentId = yield* decodeAgentIdRef(params.agent_id).pipe( + const agentId = yield* decodeAgentIdRef(agentIdRef).pipe( Effect.mapError( () => new InvalidSubagentCommandError({ message: - `agent_id "${params.agent_id ?? ''}" is not a valid subagent id. Use the agent_id line ` + + `agent_id "${agentIdRef ?? ''}" is not a valid subagent id. Use the agent_id line ` + `from a previous subagent result, or dispatch a fresh agent with the agent parameter.`, }), ), diff --git a/packages/fold-core/src/Tools/Contracts.ts b/packages/fold-core/src/Tools/Contracts.ts index f425f2e..6e8dfab 100644 --- a/packages/fold-core/src/Tools/Contracts.ts +++ b/packages/fold-core/src/Tools/Contracts.ts @@ -294,8 +294,10 @@ export const subagentToolContract = { description: 'Delegate work to a subagent with its own context window. Dispatch a fresh subagent by agent ' + 'type, resume a previous one by agent_id (its context is preserved across completions, errors, ' + - 'and interruptions), or fork a copy of your own context. The result reports the agent_id, the ' + - 'turns taken, and the final output.', + 'and interruptions), or fork a copy of your own context. Exactly one selector must be active: a ' + + 'non-empty agent, a non-empty agent_id, or fork=true. Leave inactive selectors unset or null; do ' + + 'not use empty strings or false as placeholders. The result reports the agent_id, the turns taken, ' + + 'and the final output.', parameters: SubagentParameters, success: SubagentSuccess, failure: SubagentFailure, diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 526a531..8b8c301 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -45,7 +45,13 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f { id: 'provider-call-1', name: 'subagent', - params: { description: 'map module', prompt: 'map the module', agent: 'researcher' }, + params: { + description: 'map module', + prompt: 'map the module', + agent: 'researcher', + agent_id: '', + fork: false, + }, }, ]), textTurn('synthesized'), @@ -73,7 +79,13 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f { id: 'provider-call-2', name: 'subagent', - params: { description: 'follow up', prompt: 'keep going', agent_id: shortAgentId(started.agentId) }, + params: { + description: 'follow up', + prompt: 'keep going', + agent: '', + agent_id: shortAgentId(started.agentId), + fork: false, + }, }, ]), textTurn('synthesized again'), @@ -119,7 +131,14 @@ it.effect('the public fork wire persists completed-history selection', () => { id: 'provider-call-1', name: 'subagent', - params: { description: 'inspect context', prompt: 'inspect the completed context', fork: true }, + params: { + description: 'inspect context', + prompt: 'inspect the completed context', + agent: '', + agent_id: '', + fork: true, + skill: '', + }, }, ]), textTurn('fork findings'), @@ -163,7 +182,13 @@ it.effect('malformed wire commands come back as instructive tool failures the mo { id: 'provider-call-1', name: 'subagent', - params: { description: 'oops', prompt: 'do something' }, + params: { + description: 'oops', + prompt: 'do something', + agent: '', + agent_id: '', + fork: false, + }, }, ]), toolCallTurn([ From 08d22af69b24404ba628ff25a4ad60354767a3ec Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 18:25:40 -0700 Subject: [PATCH 2/5] refactor: avoid conditional subagent command spreads HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05a5f-b382-78e4-8082-ead4ae940a0b --- packages/fold-core/src/Subagents/Schemas.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index 56f5f7d..21e6265 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -134,16 +134,29 @@ export const parseSubagentCommand = ( } if (agent !== undefined) { + if (description === undefined) { + return DispatchSubagentCommand.make({ + agent, + prompt: params.prompt, + skill, + }) + } return DispatchSubagentCommand.make({ agent, - ...(description === undefined ? {} : { description }), + description, prompt: params.prompt, skill, }) } if (params.fork === true) { + if (description === undefined) { + return ForkSubagentCommand.make({ + prompt: params.prompt, + skill, + }) + } return ForkSubagentCommand.make({ - ...(description === undefined ? {} : { description }), + description, prompt: params.prompt, skill, }) From 57caa7a08e5674f4701f129e3a49976be02e8c0f Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 18:47:34 -0700 Subject: [PATCH 3/5] chore: ban conditional empty object spreads HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05a5f-b382-78e4-8082-ead4ae940a0b --- .oxlintrc.jsonc | 1 + .../src/Compatibility/GrokCompatibility.ts | 16 +- .../fold-agent/src/Config/ConfigSchemaJson.ts | 6 +- .../fold-agent/src/Config/ModelSelections.ts | 30 +-- .../fold-agent/src/Config/ProviderConfig.ts | 14 +- .../fold-agent/src/EventLog/JsonlLayer.ts | 18 +- packages/fold-agent/src/Mode/Launch.ts | 218 ++++++++++-------- packages/fold-agent/src/Mode/Mode.ts | 14 +- packages/fold-agent/src/Mode/Rlm.ts | 19 +- packages/fold-agent/src/Mode/Rpi.ts | 32 +-- packages/fold-agent/src/Mode/Subagents.ts | 5 +- .../fold-agent/src/Session/SessionLayout.ts | 14 +- .../fold-agent/src/Tools/WebSearchTool.ts | 6 +- .../test/Config/ConfigProfiles.vi.test.ts | 15 +- packages/fold-cli/src/Commands.ts | 90 ++++---- packages/fold-cli/src/Run.ts | 45 ++-- .../fold-cli/src/tui/ActivityIndicator.tsx | 19 +- packages/fold-cli/src/tui/HostedTuiSession.ts | 38 +-- packages/fold-cli/src/tui/LaunchRequests.ts | 53 ++--- .../fold-cli/src/tui/ModelSelectionState.ts | 29 ++- packages/fold-cli/src/tui/NewSessionModal.tsx | 26 ++- .../fold-cli/src/tui/ProviderConfigState.ts | 14 +- .../fold-cli/src/tui/TuiSessionWorkspace.ts | 56 ++--- packages/fold-codex/src/AuthStore.ts | 17 +- packages/fold-codex/src/CodexModel.ts | 10 +- packages/fold-codex/src/OAuthFlows.ts | 56 +++-- .../src/AgentRuntime/AgentRuntimeLayer.ts | 95 +++++--- packages/fold-core/src/Api/Provisioning.ts | 26 ++- packages/fold-core/src/Api/StartSession.ts | 52 +++-- packages/fold-core/src/Api/ToolDefinition.ts | 18 +- .../src/EventLog/StoredLogEntryDecoder.ts | 9 +- .../src/Model/ModelRequestSettings.ts | 24 +- .../fold-core/src/Projection/Projection.ts | 30 ++- .../fold-core/src/Subagents/SubagentTool.ts | 4 +- .../fold-core/src/Subagents/SubagentsLayer.ts | 24 +- .../src/ToolRuntime/ToolRuntimeLayer.ts | 67 ++++-- .../EventLog/StoredLogEntryDecoder.vi.test.ts | 43 ++-- .../fold-core/test/Subagents/DriveHarness.ts | 14 +- packages/fold-opencode/src/OpenCodeAuth.ts | 21 +- packages/fold-opencode/src/OpenCodeModel.ts | 13 +- packages/fold-tui-theme/src/github/client.ts | 10 +- packages/fold-xai/src/AuthStore.ts | 17 +- packages/fold-xai/src/OAuthFlows.ts | 2 +- scripts/build/binaries.ts | 19 +- scripts/release/prepare.ts | 31 ++- tools/oxlint/anti-slop/index.ts | 2 + .../no-conditional-empty-object-spread.ts | 47 ++++ 47 files changed, 861 insertions(+), 568 deletions(-) create mode 100644 tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index f4eb6cc..83d01b2 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -9,6 +9,7 @@ ], "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**", "tools/oxlint/**"], "rules": { + "anti-slop/no-conditional-empty-object-spread": "error", "anti-slop/no-module-mocking": "error", "anti-slop/no-object-parameters": "error", "automation/no-shadowed-standard-array-static": "error", diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts index 8e699cb..7a82c92 100644 --- a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -19,13 +19,19 @@ export type GrokCompatibility = { export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(function* ( options: GrokCompatibilityOptions, ) { - const pluginOptions = { + const pluginOptions: { + cwd: string + home?: string + grokHome?: string + projectRoot?: string + configuredPaths?: ReadonlyArray + } = { cwd: options.cwd, - ...(options.home === undefined ? {} : { home: options.home }), - ...(options.grokHome === undefined ? {} : { grokHome: options.grokHome }), - ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), - ...(options.configuredPluginPaths === undefined ? {} : { configuredPaths: options.configuredPluginPaths }), } + if (options.home !== undefined) pluginOptions.home = options.home + if (options.grokHome !== undefined) pluginOptions.grokHome = options.grokHome + if (options.projectRoot !== undefined) pluginOptions.projectRoot = options.projectRoot + if (options.configuredPluginPaths !== undefined) pluginOptions.configuredPaths = options.configuredPluginPaths const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions) const instructions = yield* loadGrokInstructions(options) const skills = yield* makeGrokSkillSource({ diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 672d1a8..330ecd8 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -23,12 +23,12 @@ export const configSchemaPath = (foldHome?: string): string => join(foldHome ?? export const foldConfigJsonSchema = (): Record => { const document = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(FoldConfig)) const hasDefinitions = Object.keys(document.definitions).length > 0 - - return { + const schema = { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, ...document.schema, - ...(hasDefinitions ? { definitions: document.definitions } : {}), } + + return hasDefinitions ? { ...schema, definitions: document.definitions } : schema } /** The generated schema serialized as JSON text (tab-indented, trailing newline). */ diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index 3df12c8..480f055 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -102,13 +102,9 @@ export const describeModelConfiguration = ( const rolesForProfile = (config: FoldConfig, name: string): FoldConfig['roles'] | null => { if (name === 'default') return config.roles const profile = config.profiles?.[name] - return profile === undefined - ? null - : { - smart: profile.smart, - fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - } + if (profile === undefined) return null + if (profile.orchestrator === undefined) return { smart: profile.smart, fast: profile.fast } + return { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } } type DirectProviderSelection = { @@ -143,15 +139,19 @@ export const rolesForDirectProviderSelection = ( selection: DirectProviderSelection, ): FoldConfig['roles'] => { const models = defaultModelsForProvider(config, selection) - const bindingFor = (role: ConfigRole): RoleBinding => ({ - provider: selection.provider, - ...(models[role] === undefined ? {} : { model: models[role] }), - }) - const root: RoleBinding = { - ...bindingFor(rootRole), - ...(selection.model === undefined ? {} : { model: selection.model }), - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), + const bindingFor = (role: ConfigRole): RoleBinding => { + const model = models[role] + return model === undefined ? { provider: selection.provider } : { provider: selection.provider, model } } + const defaultRoot = bindingFor(rootRole) + const root: RoleBinding = + selection.model === undefined + ? selection.reasoning === undefined + ? defaultRoot + : { ...defaultRoot, reasoning: selection.reasoning } + : selection.reasoning === undefined + ? { ...defaultRoot, model: selection.model } + : { ...defaultRoot, model: selection.model, reasoning: selection.reasoning } return { orchestrator: rootRole === 'orchestrator' ? root : bindingFor('orchestrator'), smart: rootRole === 'smart' ? root : bindingFor('smart'), diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index e4beed9..1d31c72 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -150,13 +150,19 @@ export const configureProvider = ( const config = yield* loadFoldConfig(options) const previousModels = config.providers[name]?.configuredModels ?? [] const configuredModels = model === undefined ? previousModels : [...new Set([...previousModels, model])] - const provider = { + const provider: { + kind: ProviderKind + baseUrl: string + apiKey?: string + apiKeyEnv?: string + configuredModels?: ReadonlyArray + } = { kind: input.kind, baseUrl, - ...(apiKey === undefined ? {} : { apiKey }), - ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }), - ...(configuredModels.length === 0 ? {} : { configuredModels }), } + if (apiKey !== undefined) provider.apiKey = apiKey + if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv + if (configuredModels.length > 0) provider.configuredModels = configuredModels const updated: FoldConfig = { ...config, providers: { ...config.providers, [name]: provider } } yield* writeConfig(updated, options) diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index f2d9128..bd498dd 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -38,14 +38,16 @@ const unavailableError = ( cause, }) -const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => - new EventLogCorruptEntryError({ - operation: 'entries', - message, - line, - ...(seq === undefined ? {} : { seq }), - ...(cause === undefined ? {} : { cause }), - }) +const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => { + if (seq === undefined) { + return cause === undefined + ? new EventLogCorruptEntryError({ operation: 'entries', message, line }) + : new EventLogCorruptEntryError({ operation: 'entries', message, line, cause }) + } + return cause === undefined + ? new EventLogCorruptEntryError({ operation: 'entries', message, line, seq }) + : new EventLogCorruptEntryError({ operation: 'entries', message, line, seq, cause }) +} const invalidEntryError = (message: string, cause: unknown) => new EventLogInvalidEntryError({ diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 19cc971..f84c134 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -209,11 +209,10 @@ const resolveProfileSelection = ( }) } - const roles = { - smart: profile.smart, - fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - } + const roles = + profile.orchestrator === undefined + ? { smart: profile.smart, fast: profile.fast } + : { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } return { options: { ...opts, config: { ...config, roles } }, profileMode: profile.mode ?? null } }) @@ -245,11 +244,8 @@ export const mergeModelSelection = (config: FoldConfig, base: RoleBinding, selec const model = selection.model ?? (providerKindChanged ? undefined : base.model) const reasoning = selection.reasoning ?? base.reasoning - return { - provider, - ...(model === undefined ? {} : { model }), - ...(reasoning === undefined ? {} : { reasoning }), - } + if (model === undefined) return reasoning === undefined ? { provider } : { provider, reasoning } + return reasoning === undefined ? { provider, model } : { provider, model, reasoning } } const withSelectedRoleBinding = (config: FoldConfig, role: ConfigRole, binding: RoleBinding): FoldConfig => ({ @@ -287,27 +283,31 @@ const resolveModeModels = ( const config = options.config ?? (yield* loadFoldConfig(options.foldHome === undefined ? {} : { foldHome: options.foldHome })) - const selectedConfig = - selection.provider !== undefined - ? { - ...config, - roles: rolesForDirectProviderSelection(config, role, { - provider: selection.provider, - ...(selection.model === undefined ? {} : { model: selection.model }), - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), - }), - } - : selection.model === undefined && selection.reasoning === undefined - ? config - : withSelectedRoleBinding( - config, - role, - mergeModelSelection(config, roleBindingFor(config, role), selection), - ) - const models = agentModelsFromConfig(selectedConfig, { - ...(options.env === undefined ? {} : { env: options.env }), - catalog, - }) + let selectedConfig: FoldConfig + if (selection.provider !== undefined) { + const directProviderSelection = + selection.model === undefined + ? selection.reasoning === undefined + ? { provider: selection.provider } + : { provider: selection.provider, reasoning: selection.reasoning } + : selection.reasoning === undefined + ? { provider: selection.provider, model: selection.model } + : { provider: selection.provider, model: selection.model, reasoning: selection.reasoning } + selectedConfig = { + ...config, + roles: rolesForDirectProviderSelection(config, role, directProviderSelection), + } + } else if (selection.model === undefined && selection.reasoning === undefined) { + selectedConfig = config + } else { + selectedConfig = withSelectedRoleBinding( + config, + role, + mergeModelSelection(config, roleBindingFor(config, role), selection), + ) + } + const modelOptions = options.env === undefined ? { catalog } : { env: options.env, catalog } + const models = agentModelsFromConfig(selectedConfig, modelOptions) return { primary: yield* models.resolve(role), @@ -341,10 +341,7 @@ const buildAgentDefinition = ( outputStore: OutputStoreService, ): Effect.Effect => Effect.gen(function* () { - const memoryBlock = yield* memoryPromptBlock({ - cwd, - ...(options.home === undefined ? {} : { home: options.home }), - }) + const memoryBlock = yield* memoryPromptBlock(options.home === undefined ? { cwd } : { cwd, home: options.home }) // Effective RPI: the flag, or the mode's own default (RLM always carries the specialists). const rpi = options.rpi === true || mode.rpiByDefault === true const tools = [...mode.buildTools({ cwd, models, rpi, outputStore }), ...(options.extraTools ?? [])] @@ -356,15 +353,14 @@ const buildAgentDefinition = ( foldInfoBlock(options.foldHome ?? defaultFoldHome()), ] const autoCompact = options.autoCompact ?? config?.compaction ?? defaultAutoCompact - - return defineAgent({ + const agentOptions = { name: options.name ?? mode.name, model: models.primary, tools, - ...(blocks.length === 0 ? {} : { systemPrompt: blocks }), autoCompact, stopConditions: options.stopConditions ?? config?.stopConditions ?? defaultStopConditions, - }) + } + return defineAgent(blocks.length === 0 ? agentOptions : { ...agentOptions, systemPrompt: blocks }) }) /** @@ -396,19 +392,37 @@ export const switchSessionMode = ( const catalog = yield* catalogFor(profiled) const models = yield* resolveModeModels(profiled, mode, catalog) const config = yield* runtimeConfigFor(profiled) - const outputStore = yield* makeOutputStore({ - sessionId: session.sessionId, - ...(profiled.foldHome === undefined ? {} : { foldHome: profiled.foldHome }), - }) + const outputStore = yield* makeOutputStore( + profiled.foldHome === undefined + ? { sessionId: session.sessionId } + : { sessionId: session.sessionId, foldHome: profiled.foldHome }, + ) yield* outputStore.sweep const agent = yield* buildAgentDefinition(profiled, mode, models, cwd, config, outputStore) - yield* session.switchModel(models.primary, { - ...(agent.systemPrompt === undefined ? {} : { systemPrompt: agent.systemPrompt }), - ...(agent.tools === undefined ? {} : { tools: agent.tools }), + const defaultSwitchOptions = { reason: options.reason ?? `switch mode to ${mode.name}`, profiles: sessionProfilesFor(models), - }) + } + const switchOptions = + agent.systemPrompt === undefined + ? agent.tools === undefined + ? defaultSwitchOptions + : { + ...defaultSwitchOptions, + tools: agent.tools, + } + : agent.tools === undefined + ? { + ...defaultSwitchOptions, + systemPrompt: agent.systemPrompt, + } + : { + ...defaultSwitchOptions, + systemPrompt: agent.systemPrompt, + tools: agent.tools, + } + yield* session.switchModel(models.primary, switchOptions) }) const withGeneratedTitles = ( @@ -444,21 +458,22 @@ const withGeneratedTitles = ( return generateSessionTitle(entries, session.rootAgentId, model).pipe( Effect.flatMap((title) => { const generatedThroughSeq = entries.at(-1)?.seq - return session - .setTitle(title, { - ...(generatedThroughSeq === undefined - ? {} - : { generatedThroughSeq }), - rootUserTurns: rootUsers.length, - }) - .pipe( - Effect.andThen( - refreshSessionSummaryIndex( - session.sessionId, - options, - ).pipe(Effect.provide(fsLayer)), + const setTitle = + generatedThroughSeq === undefined + ? session.setTitle(title, { + rootUserTurns: rootUsers.length, + }) + : session.setTitle(title, { + generatedThroughSeq, + rootUserTurns: rootUsers.length, + }) + return setTitle.pipe( + Effect.andThen( + refreshSessionSummaryIndex(session.sessionId, options).pipe( + Effect.provide(fsLayer), ), - ) + ), + ) }), ) }), @@ -486,10 +501,11 @@ const catalogFor = ( ): Effect.Effect, never, FileSystem.FileSystem> => options.catalog !== undefined ? Effect.succeed(options.catalog) - : loadModelCatalog({ - foldHome: options.foldHome ?? defaultFoldHome(), - ...(options.env === undefined ? {} : { env: options.env }), - }) + : loadModelCatalog( + options.env === undefined + ? { foldHome: options.foldHome ?? defaultFoldHome() } + : { foldHome: options.foldHome ?? defaultFoldHome(), env: options.env }, + ) /** * Start a fresh coding session: resolve the model, load agentfiles, build the mode's tools, and @@ -507,18 +523,18 @@ export const launchSession = ( const catalog = yield* catalogFor(opts) const models = yield* resolveModeModels(opts, mode, catalog) const config = yield* runtimeConfigFor(opts) - const prepared = yield* prepareSessionLog({ - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) - const outputStore = yield* makeOutputStore({ - sessionId: prepared.sessionId, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const prepared = yield* prepareSessionLog( + opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, + ) + const outputStore = yield* makeOutputStore( + opts.foldHome === undefined + ? { sessionId: prepared.sessionId } + : { sessionId: prepared.sessionId, foldHome: opts.foldHome }, + ) yield* outputStore.sweep const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config, outputStore) - const session = yield* startSession({ + const startOptions = { agent, log: prepared.log, cwd, @@ -531,12 +547,15 @@ export const launchSession = ( profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), - ...(opts.steering === undefined ? {} : { steering: opts.steering }), - }) - return yield* withGeneratedTitles(session, models.fast, { - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + } + const session = yield* startSession( + opts.steering === undefined ? startOptions : { ...startOptions, steering: opts.steering }, + ) + return yield* withGeneratedTitles( + session, + models.fast, + opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, + ) }) const resumeFromLog = ( @@ -550,25 +569,29 @@ const resumeFromLog = ( const catalog = yield* catalogFor(options) const models = yield* resolveModeModels(options, mode, catalog) const config = yield* runtimeConfigFor(options) - const outputStore = yield* makeOutputStore({ - sessionId: log.sessionId, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + const outputStore = yield* makeOutputStore( + options.foldHome === undefined + ? { sessionId: log.sessionId } + : { sessionId: log.sessionId, foldHome: options.foldHome }, + ) yield* outputStore.sweep const agent = yield* buildAgentDefinition(options, mode, models, cwd, config, outputStore) - const session = yield* resumeSession({ + const resumeOptions = { agent, log: jsonlEventLog(log.path), profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), - ...(options.steering === undefined ? {} : { steering: options.steering }), - }) - return yield* withGeneratedTitles(session, models.fast, { - cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + } + const session = yield* resumeSession( + options.steering === undefined ? resumeOptions : { ...resumeOptions, steering: options.steering }, + ) + return yield* withGeneratedTitles( + session, + models.fast, + options.foldHome === undefined ? { cwd } : { cwd, foldHome: options.foldHome }, + ) }) /** @@ -584,10 +607,7 @@ export const resumeLatestSession = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const latest = yield* latestSessionLog({ - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const latest = yield* latestSessionLog(opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }) if (latest === null) return yield* new NoSessionToResumeError({ cwd }) return yield* resumeFromLog(latest, opts, mode, cwd) @@ -607,10 +627,10 @@ export const resumeSessionById = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const log = yield* sessionLogById(sessionId, { - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const log = yield* sessionLogById( + sessionId, + opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, + ) if (log === null) return yield* new SessionToResumeNotFoundError({ cwd, sessionId }) return yield* resumeFromLog(log, opts, mode, cwd) diff --git a/packages/fold-agent/src/Mode/Mode.ts b/packages/fold-agent/src/Mode/Mode.ts index 743e6d0..a01ccb1 100644 --- a/packages/fold-agent/src/Mode/Mode.ts +++ b/packages/fold-agent/src/Mode/Mode.ts @@ -68,9 +68,13 @@ export const defaultCodingMode: FoldMode = { name: 'coding', role: 'smart', systemPrompt: DEFAULT_CODING_PROMPT, - buildTools: ({ cwd, rpi, outputStore }) => [ - ...codingTools({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents({ cwd, rpi, ...(outputStore === undefined ? {} : { outputStore }) })), - ], + buildTools: ({ cwd, rpi, outputStore }) => { + const codingOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } + const subagentOptions = outputStore === undefined ? { cwd, rpi } : { cwd, rpi, outputStore } + return [ + ...codingTools(codingOptions), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents(subagentOptions)), + ] + }, } diff --git a/packages/fold-agent/src/Mode/Rlm.ts b/packages/fold-agent/src/Mode/Rlm.ts index 41ef3d4..f13e633 100644 --- a/packages/fold-agent/src/Mode/Rlm.ts +++ b/packages/fold-agent/src/Mode/Rlm.ts @@ -57,12 +57,15 @@ export const rlmMode: FoldMode = { // RLM always carries the RPI specialists (user ruling 2026-07-09): an orchestrator with no bash // lives and dies by the quality of its delegates, so the full specialist roster is the default. rpiByDefault: true, - buildTools: ({ cwd, rpi, outputStore }) => [ - readTool({ cwd }), - writeTool({ cwd }), - editTool({ cwd }), - applyPatchTool({ cwd }), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents({ cwd, rpi, ...(outputStore === undefined ? {} : { outputStore }) })), - ], + buildTools: ({ cwd, rpi, outputStore }) => { + const subagentOptions = outputStore === undefined ? { cwd, rpi } : { cwd, rpi, outputStore } + return [ + readTool({ cwd }), + writeTool({ cwd }), + editTool({ cwd }), + applyPatchTool({ cwd }), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents(subagentOptions)), + ] + }, } diff --git a/packages/fold-agent/src/Mode/Rpi.ts b/packages/fold-agent/src/Mode/Rpi.ts index 94a8104..cf48847 100644 --- a/packages/fold-agent/src/Mode/Rpi.ts +++ b/packages/fold-agent/src/Mode/Rpi.ts @@ -662,7 +662,7 @@ export const rpiSubagents = ({ delegates, }: RpiSubagentOptions): ReadonlyArray => { const read = readTool({ cwd }) - const bashOptions = { cwd, ...(outputStore === undefined ? {} : { outputStore }) } + const bashOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } const bash = bashTool(bashOptions) const readAndBash = [read, bash] const coding = codingTools(bashOptions) @@ -764,18 +764,24 @@ const delegateByName = (roster: ReadonlyArray, name: string) * Shared by `defaultCodingMode` and `rlmMode` so the roster composition never diverges between modes. */ export const modeSubagents = ({ cwd, outputStore, rpi }: ModeSubagentOptions): ReadonlyArray => { - const roster = defaultSubagents({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }) + const rosterOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } + const roster = defaultSubagents(rosterOptions) if (!rpi) return roster - return [ - ...roster, - ...rpiSubagents({ - cwd, - ...(outputStore === undefined ? {} : { outputStore }), - delegates: { - bash: delegateByName(roster, 'bash'), - generalPurpose: delegateByName(roster, 'general-purpose'), - }, - }), - ] + const delegates = { + bash: delegateByName(roster, 'bash'), + generalPurpose: delegateByName(roster, 'general-purpose'), + } + const specialistOptions = + outputStore === undefined + ? { + cwd, + delegates, + } + : { + cwd, + outputStore, + delegates, + } + return [...roster, ...rpiSubagents(specialistOptions)] } diff --git a/packages/fold-agent/src/Mode/Subagents.ts b/packages/fold-agent/src/Mode/Subagents.ts index bef87a4..87dad5f 100644 --- a/packages/fold-agent/src/Mode/Subagents.ts +++ b/packages/fold-agent/src/Mode/Subagents.ts @@ -153,10 +153,11 @@ export const WEB_SEARCH_RESEARCHER_PROMPT: string = * binds its model by profile role, resolved through the session's profiles map at each dispatch. */ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): ReadonlyArray => { - const coding = codingTools({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }) + const toolOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } + const coding = codingTools(toolOptions) const skills = skillTool(skillsFromDisk({ cwd })) const web = webTools() - const bashOptions = { cwd, ...(outputStore === undefined ? {} : { outputStore }) } + const bashOptions = toolOptions const bash = defineSubagent({ name: 'bash', diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index f3d576d..f5fb8df 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -335,11 +335,12 @@ export const listSessionSummaries = ( if (isCacheHit(cached, ref)) { // Explicitly construct to ensure size conforms to SessionLogRef's optional semantics. const summary = cached.summary - return Effect.succeed({ - sessionId: summary.sessionId, - path: ref.path, - mtimeMs: ref.mtimeMs, - ...(ref.size === undefined ? {} : { size: ref.size }), + const sessionRef = + ref.size === undefined + ? { sessionId: summary.sessionId, path: ref.path, mtimeMs: ref.mtimeMs } + : { sessionId: summary.sessionId, path: ref.path, mtimeMs: ref.mtimeMs, size: ref.size } + const cachedSummary = { + ...sessionRef, title: summary.title, status: summary.status, turns: summary.turns, @@ -350,7 +351,8 @@ export const listSessionSummaries = ( mode: summary.mode, rpi: summary.rpi, profile: summary.profile, - }) + } + return Effect.succeed(cachedSummary) } return loadSessionSummary(ref).pipe( Effect.tap((summary) => diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index b372b51..cec0fe0 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -30,10 +30,8 @@ const resolveExaUrl = (options?: WebSearchToolOptions): string => { const resolveParallelHeaders = (options?: WebSearchToolOptions): Record => { const apiKey = options?.parallelApiKey ?? resolveEnv(options, 'PARALLEL_API_KEY') - return { - 'User-Agent': 'fold/1.0', - ...(apiKey === undefined || apiKey.length === 0 ? {} : { Authorization: `Bearer ${apiKey}` }), - } + if (apiKey === undefined || apiKey.length === 0) return { 'User-Agent': 'fold/1.0' } + return { 'User-Agent': 'fold/1.0', Authorization: `Bearer ${apiKey}` } } const checksum = (text: string): number => { diff --git a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts index 2749e52..792ff84 100644 --- a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts @@ -92,14 +92,13 @@ const profileBindings = (profile: ProfileConfig): ReadonlyArray => ] /** Substitute one profile's roles as the config's active roles (what --profile does at launch). */ -const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfig => ({ - ...config, - roles: { - smart: profile.smart, - fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - }, -}) +const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfig => { + const roles = + profile.orchestrator === undefined + ? { smart: profile.smart, fast: profile.fast } + : { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } + return { ...config, roles } +} it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex everything-max RLM presets', () => Effect.gen(function* () { diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 4d1e62e..46a9e34 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -39,6 +39,8 @@ import { ResumeTarget, runPrompt, type CliSessionOptions } from './Run' declare const FOLD_VERSION: string const version = typeof FOLD_VERSION === 'string' ? FOLD_VERSION : '0.0.0' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + const decodeSessionId = Schema.decodeUnknownOption(SessionId) /** The sentinel `--resume` value selecting the newest session log for the working directory. */ @@ -201,7 +203,9 @@ const providerId = (provider: Option.Option, fallback: string): string = const providerAuthStoreOptions = (provider: Option.Option, foldHome: string | undefined, fallback: string) => { const path = authStorePath(foldHome) - return { providerId: providerId(provider, fallback), ...(path === undefined ? {} : { path }) } + const options: Mutable = { providerId: providerId(provider, fallback) } + if (path !== undefined) options.path = path + return options } const codexAuthStoreOptions = ( @@ -209,7 +213,9 @@ const codexAuthStoreOptions = ( foldHome: string | undefined, ): MakeCodexAuthStoreOptions => { const path = authStorePath(foldHome) - return { providerId: codexProviderId(provider), ...(path === undefined ? {} : { path }) } + const options: Mutable = { providerId: codexProviderId(provider) } + if (path !== undefined) options.path = path + return options } const browserOpenCommand = (url: string): { readonly command: string; readonly args: ReadonlyArray } => { @@ -269,14 +275,14 @@ const modelSelectionFromFlags = (input: { const model = optionValue(input.model) const reasoning = optionValue(input.reasoning) - return role === undefined && provider === undefined && model === undefined && reasoning === undefined - ? undefined - : { - ...(role === undefined ? {} : { role }), - ...(provider === undefined ? {} : { provider }), - ...(model === undefined ? {} : { model }), - ...(reasoning === undefined ? {} : { reasoning }), - } + if (role === undefined && provider === undefined && model === undefined && reasoning === undefined) return undefined + + const selection: Mutable = {} + if (role !== undefined) selection.role = role + if (provider !== undefined) selection.provider = provider + if (model !== undefined) selection.model = model + if (reasoning !== undefined) selection.reasoning = reasoning + return selection } const autoCompactFromFlags = (input: CommonFlagValues): AutoCompactConfig | undefined => { @@ -293,15 +299,14 @@ const autoCompactFromFlags = (input: CommonFlagValues): AutoCompactConfig | unde reserveTokens !== undefined || keepRecentTokens !== undefined - return hasCompactionOptions - ? { - enabled: true, - ...(compactionPrompt === undefined ? {} : { compactionPrompt }), - ...(thresholdTokens === undefined ? {} : { thresholdTokens }), - ...(reserveTokens === undefined ? {} : { reserveTokens }), - ...(keepRecentTokens === undefined ? {} : { keepRecentTokens }), - } - : undefined + if (!hasCompactionOptions) return undefined + + const autoCompact: Mutable = { enabled: true } + if (compactionPrompt !== undefined) autoCompact.compactionPrompt = compactionPrompt + if (thresholdTokens !== undefined) autoCompact.thresholdTokens = thresholdTokens + if (reserveTokens !== undefined) autoCompact.reserveTokens = reserveTokens + if (keepRecentTokens !== undefined) autoCompact.keepRecentTokens = keepRecentTokens + return autoCompact } /** CLI rendering mode selected by `--output*` flags. */ @@ -333,16 +338,15 @@ export const sessionOptionsFromFlags = ( const modelSelection = modelSelectionFromFlags(input) const autoCompact = autoCompactFromFlags(input) - return { - cwd: optionValue(input.cwd) ?? process.cwd(), - ...(foldHome === undefined ? {} : { foldHome }), - ...(profile === undefined ? {} : { profile }), - ...(mode === undefined ? {} : { mode }), - ...(input.rpi ? { rpi: true } : {}), - ...(resume === undefined ? {} : { resume }), - ...(modelSelection === undefined ? {} : { modelSelection }), - ...(autoCompact === undefined ? {} : { autoCompact }), - } + const options: Mutable = { cwd: optionValue(input.cwd) ?? process.cwd() } + if (foldHome !== undefined) options.foldHome = foldHome + if (profile !== undefined) options.profile = profile + if (mode !== undefined) options.mode = mode + if (input.rpi) options.rpi = true + if (resume !== undefined) options.resume = resume + if (modelSelection !== undefined) options.modelSelection = modelSelection + if (autoCompact !== undefined) options.autoCompact = autoCompact + return options }) const run = Command.make('foldcode', commonFlags, (input) => @@ -402,7 +406,11 @@ const launchTui = (options: CliSessionOptions, catalog: ReadonlyArray import('@opentui/solid/preload')) const module = yield* Effect.promise(() => import('./tui/Shell')) - yield* module.runTui({ ...options, catalog, ...(prompt === undefined ? {} : { prompt }) }).pipe( + const tui = + prompt === undefined + ? module.runTui({ ...options, catalog }) + : module.runTui({ ...options, catalog, prompt }) + yield* tui.pipe( Effect.catchTags({ TuiRequiresTtyError: () => printFailure( @@ -447,7 +455,7 @@ const sessions = Command.make( Effect.gen(function* () { const cwd = optionValue(input.cwd) ?? process.cwd() const foldHome = optionValue(input.foldHome) - const sessions = yield* listSessionLogs({ cwd, ...(foldHome === undefined ? {} : { foldHome }) }) + const sessions = yield* listSessionLogs(foldHome === undefined ? { cwd } : { cwd, foldHome }) if (sessions.length === 0) { yield* Console.log(`No fold sessions for ${cwd}`) return @@ -509,17 +517,15 @@ const config = Command.make('config').pipe( const apiKeyEnv = optionValue(input.apiKeyEnv) const model = optionValue(input.model) const foldHome = optionValue(input.foldHome) - yield* configureProvider( - { - name: input.name, - kind: input.kind, - baseUrl: input.baseUrl, - ...(apiKey === undefined ? {} : { apiKey }), - ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }), - ...(model === undefined ? {} : { model }), - }, - foldHome === undefined ? {} : { foldHome }, - ) + const provider: Mutable[0]> = { + name: input.name, + kind: input.kind, + baseUrl: input.baseUrl, + } + if (apiKey !== undefined) provider.apiKey = apiKey + if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv + if (model !== undefined) provider.model = model + yield* configureProvider(provider, foldHome === undefined ? {} : { foldHome }) yield* Console.log( `Saved provider "${input.name}" in ${configPathFor(foldHome === undefined ? {} : { foldHome })}`, ) diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index be26771..755e6c2 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -84,16 +84,19 @@ type OpenedSession = { type OpenSessionError = LaunchModelError | SessionToResumeNotFoundError | NoSessionToResumeError -const launchOptions = (options: CliSessionOptions) => ({ - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.mode === undefined ? {} : { mode: modeForName(options.mode) }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(options.profile === undefined ? {} : { profile: options.profile }), - ...(options.modelSelection === undefined ? {} : { modelSelection: options.modelSelection }), - ...(options.autoCompact === undefined ? {} : { autoCompact: options.autoCompact }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), -}) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + +const launchOptions = (options: CliSessionOptions) => { + const launch: Mutable[0]> = { cwd: options.cwd } + if (options.foldHome !== undefined) launch.foldHome = options.foldHome + if (options.mode !== undefined) launch.mode = modeForName(options.mode) + if (options.rpi === true) launch.rpi = true + if (options.profile !== undefined) launch.profile = options.profile + if (options.modelSelection !== undefined) launch.modelSelection = options.modelSelection + if (options.autoCompact !== undefined) launch.autoCompact = options.autoCompact + if (options.catalog !== undefined) launch.catalog = options.catalog + return launch +} /** Start fresh, resume the project's newest log, or adopt one exact session id. */ const openSessionFor = ( @@ -112,10 +115,10 @@ const openSession = ( ): Effect.Effect => Effect.gen(function* () { const session = yield* openSessionFor(options) - const logPath = sessionLogPathFor(session.sessionId, { - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + const logPath = + options.foldHome === undefined + ? sessionLogPathFor(session.sessionId, { cwd: options.cwd }) + : sessionLogPathFor(session.sessionId, { cwd: options.cwd, foldHome: options.foldHome }) return { session, @@ -139,10 +142,9 @@ const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions if (model === null) return CredentialSummary.unknown({ detail: 'no active model row found in the session log' }) if (model.providerKind === 'codex') { - const store = yield* makeCodexAuthStore({ - providerId: model.providerId, - ...(options.foldHome === undefined ? {} : { path: join(options.foldHome, 'auth.json') }), - }).pipe(Effect.provide(NodeFileSystem.layer)) + const authStoreOptions: Mutable[0]> = { providerId: model.providerId } + if (options.foldHome !== undefined) authStoreOptions.path = join(options.foldHome, 'auth.json') + const store = yield* makeCodexAuthStore(authStoreOptions).pipe(Effect.provide(NodeFileSystem.layer)) const token = yield* store.load if (Option.isNone(token)) { return CredentialSummary.missing({ detail: `entry "${model.providerId}" in ${store.path}` }) @@ -213,17 +215,18 @@ const sessionHeader = (opened: OpenedSession, options: CliSessionOptions): Effec const credential = yield* credentialSummary(model, options) const agentMode = agentModeLabel(options) - return { + const header: Mutable = { sessionId: opened.session.sessionId, cwd: options.cwd, logPath: opened.logPath, mode: opened.mode, - ...(agentMode === undefined ? {} : { agentMode }), - ...(options.profile === undefined ? {} : { profile: options.profile }), resumeFlags: resumeFlagsFor(options), model, credential, } + if (agentMode !== undefined) header.agentMode = agentMode + if (options.profile !== undefined) header.profile = options.profile + return header }) const renderLiveEvents = ( diff --git a/packages/fold-cli/src/tui/ActivityIndicator.tsx b/packages/fold-cli/src/tui/ActivityIndicator.tsx index 9425e53..9d90e0f 100644 --- a/packages/fold-cli/src/tui/ActivityIndicator.tsx +++ b/packages/fold-cli/src/tui/ActivityIndicator.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, onCleanup } from 'solid-js' +import { createMemo, createSignal, onCleanup } from 'solid-js' import { theme } from './ThemeState' @@ -37,13 +37,18 @@ export const ActivityIndicator = (props: { }, 180) onCleanup(() => clearInterval(timer)) + const value = createMemo(() => presentation(props.state, frame())) + if (props.width === undefined) { + return ( + + {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} + + ) + } + return ( - - {`${presentation(props.state, frame()).glyph} ${props.label ?? props.state.toUpperCase()}`} + + {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} ) } diff --git a/packages/fold-cli/src/tui/HostedTuiSession.ts b/packages/fold-cli/src/tui/HostedTuiSession.ts index 5fafd39..c17c205 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -15,6 +15,8 @@ import { executeRootInputAction, unexpectedActionCauseNotice, type RootInputVerb import type { ModelSelectionRequest } from './ModelSelectionState' import { makeSessionStateFromEntries, reduceSessionEvents, type SessionState } from './SessionState' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + export type HostedTuiSessionMetadata = { readonly cwd: string readonly profile: string @@ -175,25 +177,25 @@ export const makeHostedTuiSession = ( return } setNotice('APPLYING MODEL CONFIGURATION') + const switchOptions: Mutable[1]> = { + mode: modeForName(selection.mode ?? mode()), + cwd: options.metadata.cwd, + config, + reason: `TUI switch to ${selection.mode ?? mode()} mode`, + } + if (options.foldHome !== undefined) switchOptions.foldHome = options.foldHome + if (options.catalog !== undefined) switchOptions.catalog = options.catalog + if (options.rpi === true) switchOptions.rpi = true + if (selection._tag === 'profile') { + switchOptions.profile = selection.profile + } else { + switchOptions.modelSelection = + selection.reasoning === undefined + ? { provider: selection.provider, model: selection.model } + : { provider: selection.provider, model: selection.model, reasoning: selection.reasoning } + } run( - switchSessionMode(session, { - mode: modeForName(selection.mode ?? mode()), - cwd: options.metadata.cwd, - config, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(selection._tag === 'profile' - ? { profile: selection.profile } - : { - modelSelection: { - provider: selection.provider, - model: selection.model, - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), - }, - }), - reason: `TUI switch to ${selection.mode ?? mode()} mode`, - }).pipe( + switchSessionMode(session, switchOptions).pipe( Effect.tap(() => Effect.sync(() => { setProfile(selection._tag === 'profile' ? selection.profile : 'direct') diff --git a/packages/fold-cli/src/tui/LaunchRequests.ts b/packages/fold-cli/src/tui/LaunchRequests.ts index 6bdd1c3..b97168e 100644 --- a/packages/fold-cli/src/tui/LaunchRequests.ts +++ b/packages/fold-cli/src/tui/LaunchRequests.ts @@ -5,22 +5,17 @@ import type { TuiOptions } from './TuiSessionOptions' /** Build a fresh launch request without carrying process-level model, profile, or mode choices across sessions. */ export const requestToLaunchOptions = (options: TuiOptions, request: NewSessionRequest): TuiOptions => { const { profile: _profile, modelSelection: _modelSelection, mode: _mode, ...base } = options - return { - ...base, - cwd: request.cwd, - ...(request._tag === 'profile' - ? request.profile === 'default' - ? {} - : { profile: request.profile } - : { - modelSelection: { - provider: request.provider, - model: request.model, - ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }), - }, - mode: request.mode, - }), + if (request._tag === 'profile') { + return request.profile === 'default' + ? { ...base, cwd: request.cwd } + : { ...base, cwd: request.cwd, profile: request.profile } } + + const modelSelection = + request.reasoning === undefined + ? { provider: request.provider, model: request.model } + : { provider: request.provider, model: request.model, reasoning: request.reasoning } + return { ...base, cwd: request.cwd, modelSelection, mode: request.mode } } /** Resume with the durable session's model intent instead of the process's current model selection. */ @@ -32,18 +27,20 @@ export const sessionToLaunchOptions = ( const mode = session.mode === 'rlm' ? 'rlm' : 'default' if (session.profile !== null && session.profile !== 'default') return { ...base, profile: session.profile, mode } const model = session.model - return { - ...base, - mode, - ...(model === null - ? {} + if (model === null) return { ...base, mode } + + const modelSelection = + model.role === null || model.role === 'inherit' + ? { + provider: model.providerId, + model: model.modelId, + reasoning: model.requestedReasoningLevel, + } : { - modelSelection: { - provider: model.providerId, - model: model.modelId, - reasoning: model.requestedReasoningLevel, - ...(model.role === null || model.role === 'inherit' ? {} : { role: model.role }), - }, - }), - } + provider: model.providerId, + model: model.modelId, + reasoning: model.requestedReasoningLevel, + role: model.role, + } + return { ...base, mode, modelSelection } } diff --git a/packages/fold-cli/src/tui/ModelSelectionState.ts b/packages/fold-cli/src/tui/ModelSelectionState.ts index 18c53f6..cc17c34 100644 --- a/packages/fold-cli/src/tui/ModelSelectionState.ts +++ b/packages/fold-cli/src/tui/ModelSelectionState.ts @@ -26,12 +26,9 @@ export type ModelPickerChoice = { readonly id: string; readonly label: string; r export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => request._tag === 'profile' ? request - : { - _tag: 'direct', - provider: request.provider, - model: request.model, - ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }), - } + : request.reasoning === undefined + ? { _tag: 'direct', provider: request.provider, model: request.model } + : { _tag: 'direct', provider: request.provider, model: request.model, reasoning: request.reasoning } const REASONING_LEVELS: ReadonlyArray<{ id: ReasoningLevel; label: string; detail: string }> = [ { id: 'off', label: 'Off', detail: 'No extended thinking' }, @@ -110,19 +107,19 @@ export const advanceModelPicker = ( case 'model': return { _tag: 'reasoning', selection: { _tag: 'direct', provider: state.provider, model: choice } } case 'reasoning': - return { - _tag: 'mode', - selection: state.selection, - ...(choice === 'off' ? {} : { reasoning: toReasoningLevel(choice) }), - } + return choice === 'off' + ? { _tag: 'mode', selection: state.selection } + : { _tag: 'mode', selection: state.selection, reasoning: toReasoningLevel(choice) } case 'mode': return state.selection._tag === 'profile' ? { ...state.selection, mode: choice === 'rlm' ? 'rlm' : 'default' } - : { - ...state.selection, - ...(state.reasoning === undefined ? {} : { reasoning: state.reasoning }), - mode: choice === 'rlm' ? 'rlm' : 'default', - } + : state.reasoning === undefined + ? { ...state.selection, mode: choice === 'rlm' ? 'rlm' : 'default' } + : { + ...state.selection, + reasoning: state.reasoning, + mode: choice === 'rlm' ? 'rlm' : 'default', + } } } export const retreatModelPicker = (state: ModelPickerState): ModelPickerState | null => { diff --git a/packages/fold-cli/src/tui/NewSessionModal.tsx b/packages/fold-cli/src/tui/NewSessionModal.tsx index bc0da05..002956d 100644 --- a/packages/fold-cli/src/tui/NewSessionModal.tsx +++ b/packages/fold-cli/src/tui/NewSessionModal.tsx @@ -195,14 +195,24 @@ export const NewSessionModal = (props: { if (selection._tag === 'profile') { props.onSubmit({ _tag: 'profile', profile: selection.profile, cwd: cwd() }) } else if (selection.mode !== undefined) { - props.onSubmit({ - _tag: 'direct', - provider: selection.provider, - model: selection.model, - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), - mode: selection.mode, - cwd: cwd(), - }) + const request = + selection.reasoning === undefined + ? { + _tag: 'direct' as const, + provider: selection.provider, + model: selection.model, + mode: selection.mode, + cwd: cwd(), + } + : { + _tag: 'direct' as const, + provider: selection.provider, + model: selection.model, + reasoning: selection.reasoning, + mode: selection.mode, + cwd: cwd(), + } + props.onSubmit(request) } }} /> diff --git a/packages/fold-cli/src/tui/ProviderConfigState.ts b/packages/fold-cli/src/tui/ProviderConfigState.ts index f6d0101..97e2ba0 100644 --- a/packages/fold-cli/src/tui/ProviderConfigState.ts +++ b/packages/fold-cli/src/tui/ProviderConfigState.ts @@ -6,6 +6,7 @@ export const providerFormFields: ReadonlyArray = ['kind', 'na export type ProviderForm = ConfigureProviderInput & { readonly model: string } type ConfiguredProvider = ModelConfiguration['providers'][number] +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } export type ProviderManagementRow = | { @@ -99,14 +100,14 @@ export const providerFormFor = (configuration: ModelConfiguration, name: string) const provider = configuration.providers.find((candidate) => candidate.name === name) if (provider === undefined) return emptyProviderForm() const fallback = defaults(provider.kind) - return { + const form = { kind: provider.kind, name: provider.name, baseUrl: provider.baseUrl ?? fallback.baseUrl, apiKey: '', - ...(provider.apiKeyEnv === null ? {} : { apiKeyEnv: provider.apiKeyEnv }), model: provider.models[0] ?? fallback.model, } + return provider.apiKeyEnv === null ? form : { ...form, apiKeyEnv: provider.apiKeyEnv } } const nextKinds: Record = { @@ -127,9 +128,8 @@ export const withNextProviderKind = (form: ProviderForm): ProviderForm => { export const providerInput = (form: ProviderForm): ConfigureProviderInput => { const { model, apiKey, ...required } = form const oauth = form.kind === 'codex' || form.kind === 'opencode' || form.kind === 'xai' - return { - ...required, - ...(oauth || apiKey === undefined || apiKey.trim() === '' ? {} : { apiKey }), - ...(model.trim() === '' ? {} : { model }), - } + const input: Mutable = { ...required } + if (!oauth && apiKey !== undefined && apiKey.trim() !== '') input.apiKey = apiKey + if (model.trim() !== '') input.model = model + return input } diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index 495381d..7fe0aa2 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -24,19 +24,19 @@ import type { NewSessionRequest } from './NewSessionModal' import { projectSessionRows, type SessionRow } from './SessionListProjection' import type { TuiOptions } from './TuiSessionOptions' -const launchOptions = (options: TuiOptions) => ({ - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.mode === undefined ? {} : { mode: modeForName(options.mode) }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(options.modelSelection !== undefined - ? { modelSelection: options.modelSelection } - : options.profile === undefined - ? {} - : { profile: options.profile }), - ...(options.autoCompact === undefined ? {} : { autoCompact: options.autoCompact }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), -}) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + +const launchOptions = (options: TuiOptions) => { + const launch: Mutable[0]> = { cwd: options.cwd } + if (options.foldHome !== undefined) launch.foldHome = options.foldHome + if (options.mode !== undefined) launch.mode = modeForName(options.mode) + if (options.rpi === true) launch.rpi = true + if (options.modelSelection !== undefined) launch.modelSelection = options.modelSelection + else if (options.profile !== undefined) launch.profile = options.profile + if (options.autoCompact !== undefined) launch.autoCompact = options.autoCompact + if (options.catalog !== undefined) launch.catalog = options.catalog + return launch +} const initialSession = (options: TuiOptions) => { if (options.resume === undefined) return launchSession(launchOptions(options)) @@ -83,10 +83,9 @@ export const makeTuiSessionWorkspace = (options: { const cwdBySession = new Map() const loadSummaries = Effect.suspend(() => Effect.forEach([...cwds], (cwd) => - listSessionSummaries({ - cwd, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - }).pipe( + listSessionSummaries( + options.tui.foldHome === undefined ? { cwd } : { cwd, foldHome: options.tui.foldHome }, + ).pipe( Effect.tap((rows) => Effect.sync(() => rows.forEach((row) => cwdBySession.set(row.sessionId, cwd))), ), @@ -150,18 +149,19 @@ export const makeTuiSessionWorkspace = (options: { ) => session.pipe( Effect.provide(NodeFileSystem.layer), - Effect.flatMap((value) => - makeHostedTuiSession(value, { + Effect.flatMap((value) => { + const hostedOptions: Mutable[1]> = { metadata, initialInputFocused: focused, config: currentConfig, configNotice: options.configNotice, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - ...(options.tui.catalog === undefined ? {} : { catalog: options.tui.catalog }), - ...(options.tui.rpi === true ? { rpi: true } : {}), onDurableSummaryChange: refresh, - }), - ), + } + if (options.tui.foldHome !== undefined) hostedOptions.foldHome = options.tui.foldHome + if (options.tui.catalog !== undefined) hostedOptions.catalog = options.tui.catalog + if (options.tui.rpi === true) hostedOptions.rpi = true + return makeHostedTuiSession(value, hostedOptions) + }), ) const finish = (hosted: HostedTuiSession) => loadSummaries.pipe( @@ -266,10 +266,10 @@ export const makeTuiSessionWorkspace = (options: { Effect.gen(function* () { const cwd = host.get(sessionId)?.cwd ?? cwdBySession.get(sessionId) ?? options.tui.cwd yield* host.close(sessionId) - const result = yield* deleteSession(sessionId, { - cwd, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - }) + const result = yield* deleteSession( + sessionId, + options.tui.foldHome === undefined ? { cwd } : { cwd, foldHome: options.tui.foldHome }, + ) setSummaries(yield* loadSummaries) setNotice( !result.deleted diff --git a/packages/fold-codex/src/AuthStore.ts b/packages/fold-codex/src/AuthStore.ts index 2c05cf2..f9bbc96 100644 --- a/packages/fold-codex/src/AuthStore.ts +++ b/packages/fold-codex/src/AuthStore.ts @@ -63,13 +63,16 @@ const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocu const decodeToken = Schema.decodeUnknownOption(CodexTokenData) -const encodeToken = (token: CodexTokenData): Record => ({ - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - ...(token.accountId === undefined ? {} : { accountId: token.accountId }), -}) +const encodeToken = (token: CodexTokenData): Record => { + const encoded: Record = { + type: token.type, + access: token.access, + refresh: token.refresh, + expires: token.expires, + } + if (token.accountId !== undefined) encoded['accountId'] = token.accountId + return encoded +} /** Build a file-backed Codex credential store. */ export const makeCodexAuthStore = ( diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index 4c3f3dc..3fa1110 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -270,11 +270,11 @@ export const makeCodexLanguageModel = ( ) const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) - const codexClient = decorateCodexClient(stockClient, { - ...defaultCodexHardening, - ...options.hardening, - ...(options.onStreamRetry === undefined ? {} : { onStreamRetry: options.onStreamRetry }), - }) + const hardening = + options.onStreamRetry === undefined + ? { ...defaultCodexHardening, ...options.hardening } + : { ...defaultCodexHardening, ...options.hardening, onStreamRetry: options.onStreamRetry } + const codexClient = decorateCodexClient(stockClient, hardening) const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') const reasoningConfig = Match.valueTags(reasoning, { diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index 522395d..0924b2c 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -93,13 +93,18 @@ const toJwtClaims = (value: unknown): Option.Option => { ? getString(organizationsValue[0]['id']) : undefined - return Option.some({ - ...(accountId === undefined ? {} : { chatgpt_account_id: accountId }), - ...(nestedAccountId === undefined - ? {} - : { 'https://api.openai.com/auth': { chatgpt_account_id: nestedAccountId } }), - ...(organizationId === undefined ? {} : { organizations: [{ id: organizationId }] }), - }) + const claims: { + chatgpt_account_id?: string + 'https://api.openai.com/auth'?: { chatgpt_account_id?: string } + organizations?: Array<{ id: string }> + } = {} + if (accountId !== undefined) claims.chatgpt_account_id = accountId + if (nestedAccountId !== undefined) { + claims['https://api.openai.com/auth'] = { chatgpt_account_id: nestedAccountId } + } + if (organizationId !== undefined) claims.organizations = [{ id: organizationId }] + + return Option.some(claims) } const decodeJwtPayload = (token: string): Option.Option => { @@ -152,13 +157,20 @@ const toTokenData = (token: TokenResponse): Effect.Effect => Effect.map(Clock.currentTimeMillis, (now) => { const accountId = extractAccountId(token) - return new CodexTokenData({ - type: 'oauth', - access: token.access_token, - refresh: token.refresh_token, - expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, - ...(accountId === undefined ? {} : { accountId }), - }) + return accountId === undefined + ? new CodexTokenData({ + type: 'oauth', + access: token.access_token, + refresh: token.refresh_token, + expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, + }) + : new CodexTokenData({ + type: 'oauth', + access: token.access_token, + refresh: token.refresh_token, + expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, + accountId, + }) }) /** Carry an account id a token response omitted forward from the previous credential. */ @@ -191,16 +203,24 @@ export const makeIssuerHttpClient = (client: HttpClient.HttpClient): HttpClient. ) const refreshError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'RefreshFailed', message, ...(cause === undefined ? {} : { cause }) }) + cause === undefined + ? new CodexAuthError({ reason: 'RefreshFailed', message }) + : new CodexAuthError({ reason: 'RefreshFailed', message, cause }) const exchangeError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'TokenExchangeFailed', message, ...(cause === undefined ? {} : { cause }) }) + cause === undefined + ? new CodexAuthError({ reason: 'TokenExchangeFailed', message }) + : new CodexAuthError({ reason: 'TokenExchangeFailed', message, cause }) const deviceFlowError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'DeviceFlowFailed', message, ...(cause === undefined ? {} : { cause }) }) + cause === undefined + ? new CodexAuthError({ reason: 'DeviceFlowFailed', message }) + : new CodexAuthError({ reason: 'DeviceFlowFailed', message, cause }) const browserFlowError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'BrowserFlowFailed', message, ...(cause === undefined ? {} : { cause }) }) + cause === undefined + ? new CodexAuthError({ reason: 'BrowserFlowFailed', message }) + : new CodexAuthError({ reason: 'BrowserFlowFailed', message, cause }) /** Refresh an access token through the issuer. The client must come from {@link makeIssuerHttpClient}. */ export const refreshAccessToken = Effect.fn('fold.codexAuth.refreshAccessToken')(function* ( diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index c81748f..2aa73b4 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -60,16 +60,14 @@ const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => - Prompt.systemMessage({ - content, - ...(cacheBreakpoint - ? { - options: { - anthropic: { cacheControl: anthropicEphemeralCacheControl }, - }, - } - : {}), - }) + cacheBreakpoint + ? Prompt.systemMessage({ + content, + options: { + anthropic: { cacheControl: anthropicEphemeralCacheControl }, + }, + }) + : Prompt.systemMessage({ content }) const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) @@ -263,19 +261,30 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, }) - const entry = yield* appendToEventLog( - LogEntryInputs['compaction']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - ...(postCompactionInstructions === null ? {} : { postCompactionInstructions }), - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - }), - ) + const compactionInput = + postCompactionInstructions === null + ? { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + compactionId: yield* ids.makeCompactionId, + prompt: planned.success.prompt, + summary: planned.success.summary, + replacesThroughSeq: planned.success.replacesThroughSeq, + tokensBefore: planned.success.tokensBefore, + } + : { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + compactionId: yield* ids.makeCompactionId, + prompt: planned.success.prompt, + summary: planned.success.summary, + postCompactionInstructions, + replacesThroughSeq: planned.success.replacesThroughSeq, + tokensBefore: planned.success.tokensBefore, + } + const entry = yield* appendToEventLog(LogEntryInputs['compaction'](compactionInput)) if (Predicate.isTagged(entry, 'compaction')) return entry return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) @@ -556,20 +565,32 @@ export const liveAgentRuntimeLayer: Layer.Layer< // prompt block set once for the starting model; both are recorded durably. const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - const entry = yield* appendToEventLog( - LogEntryInputs['agent_started']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - ...(input.promptCacheKey == null ? {} : { promptCacheKey: input.promptCacheKey }), - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - }), - ) + const agentStartedInput = + input.promptCacheKey == null + ? { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + mode: input.mode, + model: input.model, + tools: resolvedToolset.names, + skill: input.skill, + fork: input.fork, + agentType: input.agentType, + } + : { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + mode: input.mode, + model: input.model, + promptCacheKey: input.promptCacheKey, + tools: resolvedToolset.names, + skill: input.skill, + fork: input.fork, + agentType: input.agentType, + } + const entry = yield* appendToEventLog(LogEntryInputs['agent_started'](agentStartedInput)) // A fork appends no leading system message: its projection folds the forked-from agent's // history, leading blocks included, keeping the fork's prompt prefix byte-identical for diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index aa4d326..052b590 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -101,19 +101,27 @@ export const languageModelLayerFor = (model: FoldModel): Layer.Layer { - const clientLayer = OpenAiClient.layer({ - apiKey: connection.apiKey, - ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), - }).pipe(Layer.provide(FetchHttpClient.layer)) + const clientOptions = + connection.baseUrl === null + ? { apiKey: connection.apiKey } + : { apiKey: connection.apiKey, apiUrl: connection.baseUrl } + const clientLayer = OpenAiClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return OpenAiLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) }, anthropic: (connection) => { - const clientLayer = AnthropicClient.layer({ - apiKey: connection.apiKey, - transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), - }).pipe(Layer.provide(FetchHttpClient.layer)) + const clientOptions = + connection.baseUrl === null + ? { + apiKey: connection.apiKey, + transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), + } + : { + apiKey: connection.apiKey, + transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), + apiUrl: connection.baseUrl, + } + const clientLayer = AnthropicClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return AnthropicLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) }, diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index c185ac9..477f75a 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -86,7 +86,7 @@ import { type SteeringMode, } from '../Session/SessionControls' import { liveSessionLayer } from '../Session/SessionLayer' -import { Session, type SessionService, type StartedSession } from '../Session/SessionService' +import { Session, type SessionService, type StartSessionInput, type StartedSession } from '../Session/SessionService' import type { SkillSourceService } from '../Skills/SkillSource' import { StopConditions } from '../StopConditions/StopConditions' import { agentIdsFromEntries, resolveAgentIdRef } from '../Subagents/AgentIdRef' @@ -959,20 +959,42 @@ export const startSession = ( Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) - - const started = yield* graph.session - .start({ - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - ...(options.agent.promptCacheKey === undefined ? {} : { promptCacheKey: options.agent.promptCacheKey }), - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta: { - ...options.meta, - ...(options.agent.name === undefined ? {} : { agentName: options.agent.name }), - }, - ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }), - }) - .pipe(Effect.orDie) + const meta = + options.agent.name === undefined ? { ...options.meta } : { ...options.meta, agentName: options.agent.name } + const startInput: StartSessionInput = + options.agent.promptCacheKey === undefined + ? options.sessionId === undefined + ? { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + } + : { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + sessionId: options.sessionId, + } + : options.sessionId === undefined + ? { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + promptCacheKey: options.agent.promptCacheKey, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + } + : { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + promptCacheKey: options.agent.promptCacheKey, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + sessionId: options.sessionId, + } + + const started = yield* graph.session.start(startInput).pipe(Effect.orDie) return makeSessionHandle(graph, started) }) diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 49a0ea3..5249bd9 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -127,12 +127,10 @@ export const defineTool = < >( options: DefineToolOptions, ): FoldTool => { - const tool = Tool.make(options.name, { + const toolOptions = { description: options.description, - ...(options.parameters === undefined ? {} : { parameters: options.parameters }), success: options.success ?? Schema.Undefined, - ...(options.failure === undefined ? {} : { failure: options.failure }), - failureMode: 'return', + failureMode: 'return' as const, // Every tool may use the ambient per-call services; declaring them here keeps handler `R` // honest while the runtime provides all of them around each execution. dependencies: [ @@ -146,7 +144,17 @@ export const defineTool = < FileSystem.FileSystem, ...(options.dependencies ?? []), ], - }).annotate(Tool.Strict, false) + } + const tool = Tool.make( + options.name, + options.parameters === undefined + ? options.failure === undefined + ? toolOptions + : { ...toolOptions, failure: options.failure } + : options.failure === undefined + ? { ...toolOptions, parameters: options.parameters } + : { ...toolOptions, parameters: options.parameters, failure: options.failure }, + ).annotate(Tool.Strict, false) // asVoid yields the undefined value at runtime, which is exactly what Schema.Undefined encodes. const handlerWithDependencies = diff --git a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts index 1bd03b2..ad555f2 100644 --- a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts +++ b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts @@ -12,12 +12,9 @@ import { const PersistedRecord = Schema.Record(Schema.String, Schema.Unknown) const corruptEntry = (message: string, cause: unknown, seq?: number) => - new EventLogCorruptEntryError({ - operation: 'entries', - message, - ...(seq === undefined ? {} : { seq }), - cause, - }) + seq === undefined + ? new EventLogCorruptEntryError({ operation: 'entries', message, cause }) + : new EventLogCorruptEntryError({ operation: 'entries', message, seq, cause }) /** * Decode one persisted Fold event by its wire-format version. diff --git a/packages/fold-core/src/Model/ModelRequestSettings.ts b/packages/fold-core/src/Model/ModelRequestSettings.ts index e48ba2e..2ef83c9 100644 --- a/packages/fold-core/src/Model/ModelRequestSettings.ts +++ b/packages/fold-core/src/Model/ModelRequestSettings.ts @@ -155,12 +155,12 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort }) => ({ reasoning: { effort } }), }) - return (self: Effect.Effect) => - OpenAiLanguageModel.withConfigOverride(self, { - model: model.modelId, - ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...reasoning, - }) + const config = + promptCacheKey === null + ? { model: model.modelId, ...reasoning } + : { model: model.modelId, prompt_cache_key: promptCacheKey, ...reasoning } + + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } case 'codex': { @@ -170,12 +170,12 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), }) - return (self: Effect.Effect) => - OpenAiLanguageModel.withConfigOverride(self, { - model: model.modelId, - ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...reasoning, - }) + const config = + promptCacheKey === null + ? { model: model.modelId, ...reasoning } + : { model: model.modelId, prompt_cache_key: promptCacheKey, ...reasoning } + + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } case 'anthropic': { diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 38e0e11..3f16851 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -387,18 +387,24 @@ export const messagesForAgent = ( } if (compaction !== null) { - projected.push( - ProjectedMessage['compaction-summary']({ - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - ...(compaction.postCompactionInstructions === undefined - ? {} - : { postCompactionInstructions: compaction.postCompactionInstructions }), - tokensBefore: compaction.tokensBefore, - }), - ) + const summary = + compaction.postCompactionInstructions === undefined + ? ProjectedMessage['compaction-summary']({ + sourceSeq: compaction.seq, + compactionId: compaction.compactionId, + replacesThroughSeq: compaction.replacesThroughSeq, + summary: compaction.summary, + tokensBefore: compaction.tokensBefore, + }) + : ProjectedMessage['compaction-summary']({ + sourceSeq: compaction.seq, + compactionId: compaction.compactionId, + replacesThroughSeq: compaction.replacesThroughSeq, + summary: compaction.summary, + postCompactionInstructions: compaction.postCompactionInstructions, + tokensBefore: compaction.tokensBefore, + }) + projected.push(summary) } for (const entry of visibleEntries) { diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 70e9b10..5ad34f6 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -207,5 +207,7 @@ export const subagentTool = ( }), }) - return withSubagentCapabilities(tool, { agents, ...(options?.forkAgent === undefined ? {} : options) }) + return options?.forkAgent === undefined + ? withSubagentCapabilities(tool, { agents }) + : withSubagentCapabilities(tool, { agents, ...options }) } diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index 2f83cf2..d8503a9 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -808,6 +808,23 @@ export const makeSubagents = ( : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) const agentLabel = `fork of ${shortAgentId(dispatcher.agentId)}` yield* interruptNote.set(interruptedSubagentNote(agentLabel, subagentId, 0)) + const fork = + input.forkAgentDefinitionId === null + ? input.history === undefined + ? { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq } + : { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq, history: input.history } + : input.history === undefined + ? { + fromAgentId: dispatcher.agentId, + atSeq: lastEntry.seq, + definitionId: input.forkAgentDefinitionId, + } + : { + fromAgentId: dispatcher.agentId, + atSeq: lastEntry.seq, + definitionId: input.forkAgentDefinitionId, + history: input.history, + } return yield* runSubagentToResult({ subagentId, @@ -816,12 +833,7 @@ export const makeSubagents = ( parentAgentId: dispatcher.agentId, toolCallId: currentCall.toolCallId, mode: 'fork', - fork: { - fromAgentId: dispatcher.agentId, - atSeq: lastEntry.seq, - ...(input.forkAgentDefinitionId === null ? {} : { definitionId: input.forkAgentDefinitionId }), - ...(input.history === undefined ? {} : { history: input.history }), - }, + fork, model: dispatcherSnapshot.model, promptCacheKey, tools: realized.tools, diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 8142cbf..10ce034 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -118,19 +118,25 @@ const appendToolResultToEventLog = (input: { const eventLog = yield* EventLog const ids = yield* Ids const message = yield* encodedToolResultMessage(input) - - const entry = yield* eventLog - .append( - LogEntryInputs['tool-result']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - ...(input.executedInput === undefined ? {} : { executedInput: input.executedInput }), - }), - ) - .pipe(Effect.orDie) + const entryInput = + input.executedInput === undefined + ? { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + } + : { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + executedInput: input.executedInput, + } + + const entry = yield* eventLog.append(LogEntryInputs['tool-result'](entryInput)).pipe(Effect.orDie) if (Predicate.isTagged(entry, 'tool-result')) return entry @@ -469,25 +475,36 @@ const settlePreparedToolCall = (input: { output: handlerOutput, }) + if (valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params)) { + return { result: finalOutput.result, isFailure: finalOutput.isFailure } + } + return { result: finalOutput.result, isFailure: finalOutput.isFailure, - ...(valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params) - ? {} - : { executedInput: input.prepared.params }), + executedInput: input.prepared.params, } }) const append = (result: ToolResultAppendInput) => - appendToolResultToEventLog({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - result: result.result, - isFailure: result.isFailure, - ...(result.executedInput === undefined ? {} : { executedInput: result.executedInput }), - }) + result.executedInput === undefined + ? appendToolResultToEventLog({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + result: result.result, + isFailure: result.isFailure, + }) + : appendToolResultToEventLog({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + result: result.result, + isFailure: result.isFailure, + executedInput: result.executedInput, + }) const runnable = output.pipe( Effect.provideService(ToolState, toolState), diff --git a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts index 1407aae..2aebe8b 100644 --- a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts +++ b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts @@ -11,20 +11,35 @@ import { decodeStoredLogEntry, } from '../../src/index' -const sessionStartedEntry = (version?: number) => ({ - _tag: 'session_started', - seq: 0, - eventId: EventId.create(), - ts: 1, - ...(version === undefined ? {} : { version }), - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: '/tmp/fold', - sessionId: SessionId.create(), - rootAgentId: AgentId.create(), - meta: {}, -}) +const sessionStartedEntry = (version?: number) => + version === undefined + ? { + _tag: 'session_started', + seq: 0, + eventId: EventId.create(), + ts: 1, + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: '/tmp/fold', + sessionId: SessionId.create(), + rootAgentId: AgentId.create(), + meta: {}, + } + : { + _tag: 'session_started', + seq: 0, + eventId: EventId.create(), + ts: 1, + version, + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: '/tmp/fold', + sessionId: SessionId.create(), + rootAgentId: AgentId.create(), + meta: {}, + } const legacySessionTitleEntry = () => ({ _tag: 'session_title', diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index da585b4..622fc0b 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -105,14 +105,14 @@ export const makeDriveSession = (input: { ]).flat(), ) - const session = yield* startSession({ - agent: defineAgent({ - model: rootScripted.model, - systemPrompt: 'root', - tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], - }), - ...(input.profiles === undefined ? {} : { profiles: input.profiles }), + const agent = defineAgent({ + model: rootScripted.model, + systemPrompt: 'root', + tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], }) + const session = yield* startSession( + input.profiles === undefined ? { agent } : { agent, profiles: input.profiles }, + ) /** Queue one instruction; the caller decides how to run the send (await, fork, ...). */ const queue = (instruction: DriveInstruction) => Ref.set(instructions, [instruction]) diff --git a/packages/fold-opencode/src/OpenCodeAuth.ts b/packages/fold-opencode/src/OpenCodeAuth.ts index 1c8a399..66a1fe3 100644 --- a/packages/fold-opencode/src/OpenCodeAuth.ts +++ b/packages/fold-opencode/src/OpenCodeAuth.ts @@ -75,17 +75,26 @@ const credential = (client: HttpClient.HttpClient, server: string, token: typeof ) const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] const now = yield* Clock.currentTimeMillis + const metadata = + org === undefined + ? { + server, + accountID: user.id, + email: user.email, + } + : { + server, + accountID: user.id, + email: user.email, + orgID: org.id, + orgName: org.name, + } return new OpenCodeTokenData({ type: 'oauth', access: token.access_token, refresh: token.refresh_token, expires: now + token.expires_in * 1000, - metadata: { - server, - accountID: user.id, - email: user.email, - ...(org === undefined ? {} : { orgID: org.id, orgName: org.name }), - }, + metadata, }) }) diff --git a/packages/fold-opencode/src/OpenCodeModel.ts b/packages/fold-opencode/src/OpenCodeModel.ts index a648a24..3ddba15 100644 --- a/packages/fold-opencode/src/OpenCodeModel.ts +++ b/packages/fold-opencode/src/OpenCodeModel.ts @@ -110,10 +110,15 @@ export const makeOpenCodeLanguageModel = ( Effect.gen(function* () { const httpContext = yield* Layer.build(FetchHttpClient.layer) const http = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeOpenCodeAuth({ - ...(options.store === undefined ? {} : { store: options.store }), - ...(options.consoleUrl === undefined ? {} : { server: options.consoleUrl }), - }).pipe(Effect.provideService(HttpClient.HttpClient, http)) + const authOptions = + options.store === undefined + ? options.consoleUrl === undefined + ? {} + : { server: options.consoleUrl } + : options.consoleUrl === undefined + ? { store: options.store } + : { store: options.store, server: options.consoleUrl } + const auth = yield* makeOpenCodeAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, http)) const authenticated = withOpenCodeAuth(http, auth) const requestedModel = options.model ?? DEFAULT_OPENCODE_MODEL_ID const credential = yield* Effect.option(auth.get) diff --git a/packages/fold-tui-theme/src/github/client.ts b/packages/fold-tui-theme/src/github/client.ts index 247d367..8c89ce4 100644 --- a/packages/fold-tui-theme/src/github/client.ts +++ b/packages/fold-tui-theme/src/github/client.ts @@ -54,7 +54,7 @@ function parseLabels(v: unknown): Array { function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { const headRef = raw.head ? str(raw.head.ref) : '' const baseRef = raw.base ? str(raw.base.ref) : '' - return { + const item: Omit = { kind, number: num(raw.number), title: str(raw.title, '(untitled)'), @@ -68,11 +68,11 @@ function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { labels: parseLabels(raw.labels), body: str(raw.body), url: str(raw.html_url), - // `exactOptionalPropertyTypes` forbids assigning `undefined` to an - // optional prop, so omit the key entirely instead. - ...(headRef ? { headRef } : {}), - ...(baseRef ? { baseRef } : {}), } + // `exactOptionalPropertyTypes` forbids assigning `undefined` to an + // optional prop, so only add the keys when their refs are non-empty. + if (headRef) return baseRef ? { ...item, headRef, baseRef } : { ...item, headRef } + return baseRef ? { ...item, baseRef } : item } /** Env vars first, then whatever `gh` is already logged in as. */ diff --git a/packages/fold-xai/src/AuthStore.ts b/packages/fold-xai/src/AuthStore.ts index 3f8f64d..fdc3ef2 100644 --- a/packages/fold-xai/src/AuthStore.ts +++ b/packages/fold-xai/src/AuthStore.ts @@ -63,13 +63,16 @@ const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocu const decodeToken = Schema.decodeUnknownOption(XaiTokenData) -const encodeToken = (token: XaiTokenData): Record => ({ - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - ...(token.accountId === undefined ? {} : { accountId: token.accountId }), -}) +const encodeToken = (token: XaiTokenData): Record => { + const encoded: Record = { + type: token.type, + access: token.access, + refresh: token.refresh, + expires: token.expires, + } + if (token.accountId !== undefined) encoded['accountId'] = token.accountId + return encoded +} /** Build a file-backed Xai credential store. */ export const makeXaiAuthStore = ( diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index fc32202..5e8003e 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -55,7 +55,7 @@ const DeviceError = Schema.Struct({ }) const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown) => - new XaiAuthError({ reason, message, ...(cause === undefined ? {} : { cause }) }) + cause === undefined ? new XaiAuthError({ reason, message }) : new XaiAuthError({ reason, message, cause }) const tokenData = (payload: typeof TokenResponse.Type, fallbackRefresh?: string) => Effect.map( diff --git a/scripts/build/binaries.ts b/scripts/build/binaries.ts index 489211c..d92b73a 100644 --- a/scripts/build/binaries.ts +++ b/scripts/build/binaries.ts @@ -46,6 +46,17 @@ for (const target of selected) { await mkdir(outdir, { recursive: true }) const bunTarget: Bun.CompileTarget = `bun-${os === 'windows' ? 'windows' : os}-${cpu}${variant.includes('baseline') ? '-baseline' : ''}${variant.includes('musl') ? '-musl' : ''}` const bunfs = os === 'windows' ? 'B:/~BUN/root/' : '/$bunfs/root/' + const define = + os === 'linux' + ? { + FOLD_VERSION: JSON.stringify(versionArg), + OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), + 'process.env.OPENTUI_LIBC': JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc'), + } + : { + FOLD_VERSION: JSON.stringify(versionArg), + OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), + } const result = await Bun.build({ entrypoints: [join(root, 'packages/fold-cli/src/cli.ts'), parserWorker], plugins: [createSolidTransformPlugin()], @@ -53,13 +64,7 @@ for (const target of selected) { format: 'esm', minify: true, sourcemap: 'inline', - define: { - FOLD_VERSION: JSON.stringify(versionArg), - OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), - ...(os === 'linux' - ? { 'process.env.OPENTUI_LIBC': JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc') } - : {}), - }, + define, compile: { target: bunTarget, outfile: join(outdir, os === 'windows' ? 'foldcode.exe' : 'foldcode'), diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index c59a010..e11ac40 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -88,10 +88,33 @@ for (const target of targets) { await mkdir(dest, { recursive: true }) await cp(join(source, 'bin'), join(dest, 'bin'), { recursive: true }) const [os, cpu, variant] = target - await Bun.write( - join(dest, 'package.json'), - `${JSON.stringify({ name, version, description: 'Platform binary for @humanlayer/fold', license: 'MIT', repository, preferUnplugged: true, os: [os === 'windows' ? 'win32' : os], cpu: [cpu], ...(variant.includes('musl') ? { libc: ['musl'] } : {}), files: ['bin'], publishConfig: { access: 'public' } }, null, 2)}\n`, - ) + const manifest = variant.includes('musl') + ? { + name, + version, + description: 'Platform binary for @humanlayer/fold', + license: 'MIT', + repository, + preferUnplugged: true, + os: [os === 'windows' ? 'win32' : os], + cpu: [cpu], + libc: ['musl'], + files: ['bin'], + publishConfig: { access: 'public' }, + } + : { + name, + version, + description: 'Platform binary for @humanlayer/fold', + license: 'MIT', + repository, + preferUnplugged: true, + os: [os === 'windows' ? 'win32' : os], + cpu: [cpu], + files: ['bin'], + publishConfig: { access: 'public' }, + } + await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) } const platform = await json(join(root, 'packages/fold/package.json')) diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 8035521..3917980 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -1,5 +1,6 @@ import { eslintCompatPlugin } from '@oxlint/plugins' +import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty-object-spread.ts' import { noModuleMockingRule } from './rules/no-module-mocking.ts' import { noObjectParametersRule } from './rules/no-object-parameters.ts' import { noReflectApplyRule } from './rules/no-reflect-apply.ts' @@ -7,6 +8,7 @@ import { noReflectApplyRule } from './rules/no-reflect-apply.ts' export default eslintCompatPlugin({ meta: { name: 'anti-slop' }, rules: { + 'no-conditional-empty-object-spread': noConditionalEmptyObjectSpreadRule, 'no-module-mocking': noModuleMockingRule, 'no-object-parameters': noObjectParametersRule, 'no-reflect-apply': noReflectApplyRule, diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..43eced7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,47 @@ +// Vendored from https://github.com/K-Mistele/anti-slop at cf9bad836a7ba5562f5167a3471c97a1849a9f5f +// (MIT), src/rules/no-conditional-empty-object-spread.ts. +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === 'ObjectExpression' && node.properties.length === 0 +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node) + return ( + conditional.type === 'ConditionalExpression' && + (isEmptyObjectExpression(conditional.consequent) || isEmptyObjectExpression(conditional.alternate)) + ) +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: 'suggestion', + docs: { + description: 'Disallow object spreads that conditionally spread an empty object to omit fields.', + }, + messages: { + avoid: 'This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== 'ObjectExpression') return + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +}) From 5939a89efdb41171ff6498c10689ba808f274a26 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 19:22:41 -0700 Subject: [PATCH 4/5] refactor: build optional fields explicitly HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05a5f-b382-78e4-8082-ead4ae940a0b --- .../fold-agent/src/Config/ConfigSchemaJson.ts | 6 +- .../fold-agent/src/Config/ModelSelections.ts | 36 ++- .../fold-agent/src/EventLog/JsonlLayer.ts | 18 +- packages/fold-agent/src/Mode/Launch.ts | 225 +++++++++--------- packages/fold-agent/src/Mode/Mode.ts | 6 +- packages/fold-agent/src/Mode/Rlm.ts | 4 +- packages/fold-agent/src/Mode/Rpi.ts | 27 +-- packages/fold-agent/src/Mode/Subagents.ts | 8 +- .../fold-agent/src/Session/SessionLayout.ts | 13 +- .../fold-agent/src/Tools/WebSearchTool.ts | 5 +- .../test/Config/ConfigProfiles.vi.test.ts | 9 +- packages/fold-cli/src/Commands.ts | 30 ++- packages/fold-cli/src/Run.ts | 14 +- .../fold-cli/src/tui/ActivityIndicator.tsx | 23 +- packages/fold-cli/src/tui/HostedTuiSession.ts | 11 +- packages/fold-cli/src/tui/LaunchRequests.ts | 51 ++-- .../fold-cli/src/tui/ModelSelectionState.ts | 52 ++-- packages/fold-cli/src/tui/NewSessionModal.tsx | 27 +-- .../fold-cli/src/tui/ProviderConfigState.ts | 5 +- .../fold-cli/src/tui/TuiSessionWorkspace.ts | 19 +- packages/fold-codex/src/CodexModel.ts | 13 +- packages/fold-codex/src/OAuthFlows.ts | 67 +++--- .../src/AgentRuntime/AgentRuntimeLayer.ts | 94 +++----- packages/fold-core/src/Api/Provisioning.ts | 28 +-- packages/fold-core/src/Api/StartSession.ts | 52 ++-- packages/fold-core/src/Api/ToolDefinition.ts | 42 +++- .../src/EventLog/StoredLogEntryDecoder.ts | 18 +- .../src/Model/ModelRequestSettings.ts | 29 ++- .../fold-core/src/Projection/Projection.ts | 31 +-- .../fold-core/src/Subagents/SubagentTool.ts | 11 +- .../fold-core/src/Subagents/SubagentsLayer.ts | 26 +- .../src/ToolRuntime/ToolRuntimeLayer.ts | 75 +++--- .../EventLog/StoredLogEntryDecoder.vi.test.ts | 64 ++--- .../fold-core/test/Subagents/DriveHarness.ts | 10 +- packages/fold-opencode/src/OpenCodeAuth.ts | 45 ++-- packages/fold-opencode/src/OpenCodeModel.ts | 11 +- packages/fold-tui-theme/src/github/client.ts | 9 +- packages/fold-xai/src/OAuthFlows.ts | 7 +- packages/fold-xai/src/XaiModel.ts | 6 +- scripts/build/binaries.ts | 16 +- scripts/build/packages.ts | 4 +- scripts/release/prepare.ts | 52 ++-- 42 files changed, 666 insertions(+), 633 deletions(-) diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 330ecd8..5c8644c 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -23,12 +23,12 @@ export const configSchemaPath = (foldHome?: string): string => join(foldHome ?? export const foldConfigJsonSchema = (): Record => { const document = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(FoldConfig)) const hasDefinitions = Object.keys(document.definitions).length > 0 - const schema = { + const schema: Record = { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, ...document.schema, } - - return hasDefinitions ? { ...schema, definitions: document.definitions } : schema + if (hasDefinitions) schema.definitions = document.definitions + return schema } /** The generated schema serialized as JSON text (tab-indented, trailing newline). */ diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index 480f055..ade7213 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -7,6 +7,18 @@ import { Effect, Match, Predicate } from 'effect' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' +type RoleBindingBuilder = { + provider: string + model?: string + reasoning?: NonNullable +} + +type RolesBuilder = { + smart: RoleBinding + fast: RoleBinding + orchestrator?: RoleBinding +} + export type ProfileModelSelection = { readonly _tag: 'profile'; readonly profile: string } export type DirectModelSelection = { readonly _tag: 'direct' @@ -103,8 +115,10 @@ const rolesForProfile = (config: FoldConfig, name: string): FoldConfig['roles'] if (name === 'default') return config.roles const profile = config.profiles?.[name] if (profile === undefined) return null - if (profile.orchestrator === undefined) return { smart: profile.smart, fast: profile.fast } - return { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } + + const roles: RolesBuilder = { smart: profile.smart, fast: profile.fast } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator + return roles } type DirectProviderSelection = { @@ -139,19 +153,15 @@ export const rolesForDirectProviderSelection = ( selection: DirectProviderSelection, ): FoldConfig['roles'] => { const models = defaultModelsForProvider(config, selection) - const bindingFor = (role: ConfigRole): RoleBinding => { + const bindingFor = (role: ConfigRole): RoleBindingBuilder => { const model = models[role] - return model === undefined ? { provider: selection.provider } : { provider: selection.provider, model } + const binding: RoleBindingBuilder = { provider: selection.provider } + if (model !== undefined) binding.model = model + return binding } - const defaultRoot = bindingFor(rootRole) - const root: RoleBinding = - selection.model === undefined - ? selection.reasoning === undefined - ? defaultRoot - : { ...defaultRoot, reasoning: selection.reasoning } - : selection.reasoning === undefined - ? { ...defaultRoot, model: selection.model } - : { ...defaultRoot, model: selection.model, reasoning: selection.reasoning } + const root = bindingFor(rootRole) + if (selection.model !== undefined) root.model = selection.model + if (selection.reasoning !== undefined) root.reasoning = selection.reasoning return { orchestrator: rootRole === 'orchestrator' ? root : bindingFor('orchestrator'), smart: rootRole === 'smart' ? root : bindingFor('smart'), diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index bd498dd..742c218 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -39,14 +39,16 @@ const unavailableError = ( }) const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => { - if (seq === undefined) { - return cause === undefined - ? new EventLogCorruptEntryError({ operation: 'entries', message, line }) - : new EventLogCorruptEntryError({ operation: 'entries', message, line, cause }) - } - return cause === undefined - ? new EventLogCorruptEntryError({ operation: 'entries', message, line, seq }) - : new EventLogCorruptEntryError({ operation: 'entries', message, line, seq, cause }) + const input: { + operation: 'entries' + message: string + line: number + seq?: number + cause?: unknown + } = { operation: 'entries', message, line } + if (seq !== undefined) input.seq = seq + if (cause !== undefined) input.cause = cause + return new EventLogCorruptEntryError(input) } const invalidEntryError = (message: string, cause: unknown) => diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index f84c134..3c9f9d9 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -24,6 +24,9 @@ import { SessionId, startSession, type AgentDefinition, + type ResumeSessionOptions, + type StartSessionOptions, + type SwitchModelOptions, type AutoCompactConfig, type ModelCatalogEntry, type ReasoningLevel, @@ -34,11 +37,17 @@ import { type FoldSession, type FoldTool, type Ids, + type LogSeq, } from '@humanlayer/fold-core' import { Predicate, Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' -import { loadModelCatalog } from '../Catalog/LoadCatalog' -import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' +import { loadModelCatalog, type LoadModelCatalogOptions } from '../Catalog/LoadCatalog' +import { + agentModelsFromConfig, + type AgentModelsOptions, + type EnvLookup, + type RoleResolutionError, +} from '../Config/AgentModels' import type { ConfigRole, ProfileModeName, RoleBinding, FoldConfig } from '../Config/ConfigSchema' import { defaultFoldHome, @@ -46,17 +55,19 @@ import { type ConfigDecodeError, type ConfigFileNotFoundError, type ConfigParseError, + type LoadConfigOptions, } from '../Config/Load' import { rolesForDirectProviderSelection } from '../Config/ModelSelections' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' -import { memoryPromptBlock } from '../Memory/AgentFiles' -import { makeOutputStore, type OutputStoreService } from '../OutputStore/OutputStore' +import { memoryPromptBlock, type AgentFilesOptions } from '../Memory/AgentFiles' +import { makeOutputStore, type MakeOutputStoreOptions, type OutputStoreService } from '../OutputStore/OutputStore' import { latestSessionLog, prepareSessionLog, refreshSessionSummaryIndex, sessionLogById, type SessionLogRef, + type SessionLayoutOptions, } from '../Session/SessionLayout' import { generateSessionTitle } from '../Session/TitleGenerator' import { compactionArchiveAccessFor } from './CompactionArchiveAccess' @@ -65,6 +76,20 @@ import { modeForName } from './ModeName' import { RPI_HINT_PROMPT } from './Rpi' import type { ModeModels } from './Subagents' +type Mutable = { -readonly [Key in keyof Value]: Value[Key] } + +type RolesBuilder = { + smart: RoleBinding + fast: RoleBinding + orchestrator?: RoleBinding +} + +type DirectProviderSelectionBuilder = { + provider: string + model?: string + reasoning?: ReasoningLevel +} + /** A `--profile` name that is not defined under the config's `profiles` map. */ export class UnknownProfileError extends Schema.TaggedError()('UnknownProfileError', { profile: Schema.String, @@ -199,8 +224,14 @@ const resolveProfileSelection = ( Effect.gen(function* () { if (opts.profile === undefined) return { options: opts, profileMode: null } - const config = - opts.config ?? (yield* loadFoldConfig(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome })) + let config: FoldConfig + if (opts.config === undefined) { + const configOptions: Mutable = {} + if (opts.foldHome !== undefined) configOptions.foldHome = opts.foldHome + config = yield* loadFoldConfig(configOptions) + } else { + config = opts.config + } const profile = config.profiles?.[opts.profile] if (profile === undefined) { return yield* new UnknownProfileError({ @@ -209,10 +240,8 @@ const resolveProfileSelection = ( }) } - const roles = - profile.orchestrator === undefined - ? { smart: profile.smart, fast: profile.fast } - : { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } + const roles: RolesBuilder = { smart: profile.smart, fast: profile.fast } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator return { options: { ...opts, config: { ...config, roles } }, profileMode: profile.mode ?? null } }) @@ -244,8 +273,12 @@ export const mergeModelSelection = (config: FoldConfig, base: RoleBinding, selec const model = selection.model ?? (providerKindChanged ? undefined : base.model) const reasoning = selection.reasoning ?? base.reasoning - if (model === undefined) return reasoning === undefined ? { provider } : { provider, reasoning } - return reasoning === undefined ? { provider, model } : { provider, model, reasoning } + const binding: { provider: string; model?: string; reasoning?: NonNullable } = { + provider, + } + if (model !== undefined) binding.model = model + if (reasoning !== undefined) binding.reasoning = reasoning + return binding } const withSelectedRoleBinding = (config: FoldConfig, role: ConfigRole, binding: RoleBinding): FoldConfig => ({ @@ -280,33 +313,32 @@ const resolveModeModels = ( const selection = options.modelSelection ?? {} const role = selection.role ?? mode.role - const config = - options.config ?? - (yield* loadFoldConfig(options.foldHome === undefined ? {} : { foldHome: options.foldHome })) - let selectedConfig: FoldConfig + let config: FoldConfig + if (options.config === undefined) { + const configOptions: Mutable = {} + if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome + config = yield* loadFoldConfig(configOptions) + } else { + config = options.config + } + let selectedConfig = config if (selection.provider !== undefined) { - const directProviderSelection = - selection.model === undefined - ? selection.reasoning === undefined - ? { provider: selection.provider } - : { provider: selection.provider, reasoning: selection.reasoning } - : selection.reasoning === undefined - ? { provider: selection.provider, model: selection.model } - : { provider: selection.provider, model: selection.model, reasoning: selection.reasoning } + const directProviderSelection: DirectProviderSelectionBuilder = { provider: selection.provider } + if (selection.model !== undefined) directProviderSelection.model = selection.model + if (selection.reasoning !== undefined) directProviderSelection.reasoning = selection.reasoning selectedConfig = { ...config, roles: rolesForDirectProviderSelection(config, role, directProviderSelection), } - } else if (selection.model === undefined && selection.reasoning === undefined) { - selectedConfig = config - } else { + } else if (selection.model !== undefined || selection.reasoning !== undefined) { selectedConfig = withSelectedRoleBinding( config, role, mergeModelSelection(config, roleBindingFor(config, role), selection), ) } - const modelOptions = options.env === undefined ? { catalog } : { env: options.env, catalog } + const modelOptions: Mutable = { catalog } + if (options.env !== undefined) modelOptions.env = options.env const models = agentModelsFromConfig(selectedConfig, modelOptions) return { @@ -341,7 +373,9 @@ const buildAgentDefinition = ( outputStore: OutputStoreService, ): Effect.Effect => Effect.gen(function* () { - const memoryBlock = yield* memoryPromptBlock(options.home === undefined ? { cwd } : { cwd, home: options.home }) + const memoryOptions: Mutable = { cwd } + if (options.home !== undefined) memoryOptions.home = options.home + const memoryBlock = yield* memoryPromptBlock(memoryOptions) // Effective RPI: the flag, or the mode's own default (RLM always carries the specialists). const rpi = options.rpi === true || mode.rpiByDefault === true const tools = [...mode.buildTools({ cwd, models, rpi, outputStore }), ...(options.extraTools ?? [])] @@ -353,14 +387,15 @@ const buildAgentDefinition = ( foldInfoBlock(options.foldHome ?? defaultFoldHome()), ] const autoCompact = options.autoCompact ?? config?.compaction ?? defaultAutoCompact - const agentOptions = { + const agentOptions: Mutable = { name: options.name ?? mode.name, model: models.primary, tools, autoCompact, stopConditions: options.stopConditions ?? config?.stopConditions ?? defaultStopConditions, } - return defineAgent(blocks.length === 0 ? agentOptions : { ...agentOptions, systemPrompt: blocks }) + if (blocks.length > 0) agentOptions.systemPrompt = blocks + return defineAgent(agentOptions) }) /** @@ -392,36 +427,18 @@ export const switchSessionMode = ( const catalog = yield* catalogFor(profiled) const models = yield* resolveModeModels(profiled, mode, catalog) const config = yield* runtimeConfigFor(profiled) - const outputStore = yield* makeOutputStore( - profiled.foldHome === undefined - ? { sessionId: session.sessionId } - : { sessionId: session.sessionId, foldHome: profiled.foldHome }, - ) + const outputStoreOptions: Mutable = { sessionId: session.sessionId } + if (profiled.foldHome !== undefined) outputStoreOptions.foldHome = profiled.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(profiled, mode, models, cwd, config, outputStore) - const defaultSwitchOptions = { + const switchOptions: Mutable = { reason: options.reason ?? `switch mode to ${mode.name}`, profiles: sessionProfilesFor(models), } - const switchOptions = - agent.systemPrompt === undefined - ? agent.tools === undefined - ? defaultSwitchOptions - : { - ...defaultSwitchOptions, - tools: agent.tools, - } - : agent.tools === undefined - ? { - ...defaultSwitchOptions, - systemPrompt: agent.systemPrompt, - } - : { - ...defaultSwitchOptions, - systemPrompt: agent.systemPrompt, - tools: agent.tools, - } + if (agent.systemPrompt !== undefined) switchOptions.systemPrompt = agent.systemPrompt + if (agent.tools !== undefined) switchOptions.tools = agent.tools yield* session.switchModel(models.primary, switchOptions) }) @@ -458,15 +475,13 @@ const withGeneratedTitles = ( return generateSessionTitle(entries, session.rootAgentId, model).pipe( Effect.flatMap((title) => { const generatedThroughSeq = entries.at(-1)?.seq - const setTitle = - generatedThroughSeq === undefined - ? session.setTitle(title, { - rootUserTurns: rootUsers.length, - }) - : session.setTitle(title, { - generatedThroughSeq, - rootUserTurns: rootUsers.length, - }) + const provenance: { + rootUserTurns: number + generatedThroughSeq?: LogSeq + } = { rootUserTurns: rootUsers.length } + if (generatedThroughSeq !== undefined) + provenance.generatedThroughSeq = generatedThroughSeq + const setTitle = session.setTitle(title, provenance) return setTitle.pipe( Effect.andThen( refreshSessionSummaryIndex(session.sessionId, options).pipe( @@ -492,20 +507,21 @@ const runtimeConfigFor = ( if (options.config !== undefined) return Effect.succeed(options.config) if (options.model !== undefined) return Effect.succeed(null) - return loadFoldConfig(options.foldHome === undefined ? {} : { foldHome: options.foldHome }) + const configOptions: Mutable = {} + if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome + return loadFoldConfig(configOptions) } /** The catalog for a launch: the caller's (the CLI loads once), else a fresh load (never fails). */ const catalogFor = ( options: LaunchSessionOptions, -): Effect.Effect, never, FileSystem.FileSystem> => - options.catalog !== undefined - ? Effect.succeed(options.catalog) - : loadModelCatalog( - options.env === undefined - ? { foldHome: options.foldHome ?? defaultFoldHome() } - : { foldHome: options.foldHome ?? defaultFoldHome(), env: options.env }, - ) +): Effect.Effect, never, FileSystem.FileSystem> => { + if (options.catalog !== undefined) return Effect.succeed(options.catalog) + + const catalogOptions: Mutable = { foldHome: options.foldHome ?? defaultFoldHome() } + if (options.env !== undefined) catalogOptions.env = options.env + return loadModelCatalog(catalogOptions) +} /** * Start a fresh coding session: resolve the model, load agentfiles, build the mode's tools, and @@ -523,18 +539,16 @@ export const launchSession = ( const catalog = yield* catalogFor(opts) const models = yield* resolveModeModels(opts, mode, catalog) const config = yield* runtimeConfigFor(opts) - const prepared = yield* prepareSessionLog( - opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, - ) - const outputStore = yield* makeOutputStore( - opts.foldHome === undefined - ? { sessionId: prepared.sessionId } - : { sessionId: prepared.sessionId, foldHome: opts.foldHome }, - ) + const prepareOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) prepareOptions.foldHome = opts.foldHome + const prepared = yield* prepareSessionLog(prepareOptions) + const outputStoreOptions: Mutable = { sessionId: prepared.sessionId } + if (opts.foldHome !== undefined) outputStoreOptions.foldHome = opts.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config, outputStore) - const startOptions = { + const startOptions: Mutable = { agent, log: prepared.log, cwd, @@ -548,14 +562,11 @@ export const launchSession = ( catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), } - const session = yield* startSession( - opts.steering === undefined ? startOptions : { ...startOptions, steering: opts.steering }, - ) - return yield* withGeneratedTitles( - session, - models.fast, - opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, - ) + if (opts.steering !== undefined) startOptions.steering = opts.steering + const session = yield* startSession(startOptions) + const titleOptions: { cwd: string; foldHome?: string } = { cwd } + if (opts.foldHome !== undefined) titleOptions.foldHome = opts.foldHome + return yield* withGeneratedTitles(session, models.fast, titleOptions) }) const resumeFromLog = ( @@ -569,29 +580,24 @@ const resumeFromLog = ( const catalog = yield* catalogFor(options) const models = yield* resolveModeModels(options, mode, catalog) const config = yield* runtimeConfigFor(options) - const outputStore = yield* makeOutputStore( - options.foldHome === undefined - ? { sessionId: log.sessionId } - : { sessionId: log.sessionId, foldHome: options.foldHome }, - ) + const outputStoreOptions: Mutable = { sessionId: log.sessionId } + if (options.foldHome !== undefined) outputStoreOptions.foldHome = options.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(options, mode, models, cwd, config, outputStore) - const resumeOptions = { + const resumeOptions: Mutable = { agent, log: jsonlEventLog(log.path), profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), } - const session = yield* resumeSession( - options.steering === undefined ? resumeOptions : { ...resumeOptions, steering: options.steering }, - ) - return yield* withGeneratedTitles( - session, - models.fast, - options.foldHome === undefined ? { cwd } : { cwd, foldHome: options.foldHome }, - ) + if (options.steering !== undefined) resumeOptions.steering = options.steering + const session = yield* resumeSession(resumeOptions) + const sessionLayoutOptions: { cwd: string; foldHome?: string } = { cwd } + if (options.foldHome !== undefined) sessionLayoutOptions.foldHome = options.foldHome + return yield* withGeneratedTitles(session, models.fast, sessionLayoutOptions) }) /** @@ -607,7 +613,9 @@ export const resumeLatestSession = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const latest = yield* latestSessionLog(opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }) + const sessionLayoutOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome + const latest = yield* latestSessionLog(sessionLayoutOptions) if (latest === null) return yield* new NoSessionToResumeError({ cwd }) return yield* resumeFromLog(latest, opts, mode, cwd) @@ -627,10 +635,9 @@ export const resumeSessionById = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const log = yield* sessionLogById( - sessionId, - opts.foldHome === undefined ? { cwd } : { cwd, foldHome: opts.foldHome }, - ) + const sessionLayoutOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome + const log = yield* sessionLogById(sessionId, sessionLayoutOptions) if (log === null) return yield* new SessionToResumeNotFoundError({ cwd, sessionId }) return yield* resumeFromLog(log, opts, mode, cwd) diff --git a/packages/fold-agent/src/Mode/Mode.ts b/packages/fold-agent/src/Mode/Mode.ts index a01ccb1..8bbe129 100644 --- a/packages/fold-agent/src/Mode/Mode.ts +++ b/packages/fold-agent/src/Mode/Mode.ts @@ -69,8 +69,10 @@ export const defaultCodingMode: FoldMode = { role: 'smart', systemPrompt: DEFAULT_CODING_PROMPT, buildTools: ({ cwd, rpi, outputStore }) => { - const codingOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } - const subagentOptions = outputStore === undefined ? { cwd, rpi } : { cwd, rpi, outputStore } + const codingOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) codingOptions.outputStore = outputStore + const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } + if (outputStore !== undefined) subagentOptions.outputStore = outputStore return [ ...codingTools(codingOptions), skillTool(skillsFromDisk({ cwd })), diff --git a/packages/fold-agent/src/Mode/Rlm.ts b/packages/fold-agent/src/Mode/Rlm.ts index f13e633..85e39b9 100644 --- a/packages/fold-agent/src/Mode/Rlm.ts +++ b/packages/fold-agent/src/Mode/Rlm.ts @@ -10,6 +10,7 @@ */ import { skillTool, subagentTool } from '@humanlayer/fold-core' +import type { OutputStoreService } from '../OutputStore/OutputStore' import { skillsFromDisk } from '../Skills/DiskSkills' import { applyPatchTool } from '../Tools/ApplyPatchTool' import { editTool } from '../Tools/EditTool' @@ -58,7 +59,8 @@ export const rlmMode: FoldMode = { // lives and dies by the quality of its delegates, so the full specialist roster is the default. rpiByDefault: true, buildTools: ({ cwd, rpi, outputStore }) => { - const subagentOptions = outputStore === undefined ? { cwd, rpi } : { cwd, rpi, outputStore } + const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } + if (outputStore !== undefined) subagentOptions.outputStore = outputStore return [ readTool({ cwd }), writeTool({ cwd }), diff --git a/packages/fold-agent/src/Mode/Rpi.ts b/packages/fold-agent/src/Mode/Rpi.ts index cf48847..e12da47 100644 --- a/packages/fold-agent/src/Mode/Rpi.ts +++ b/packages/fold-agent/src/Mode/Rpi.ts @@ -662,10 +662,11 @@ export const rpiSubagents = ({ delegates, }: RpiSubagentOptions): ReadonlyArray => { const read = readTool({ cwd }) - const bashOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } - const bash = bashTool(bashOptions) + const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) toolOptions.outputStore = outputStore + const bash = bashTool(toolOptions) const readAndBash = [read, bash] - const coding = codingTools(bashOptions) + const coding = codingTools(toolOptions) const implementerDelegates = subagentTool([delegates.bash, delegates.generalPurpose]) const codebaseLocator = defineSubagent({ @@ -764,7 +765,8 @@ const delegateByName = (roster: ReadonlyArray, name: string) * Shared by `defaultCodingMode` and `rlmMode` so the roster composition never diverges between modes. */ export const modeSubagents = ({ cwd, outputStore, rpi }: ModeSubagentOptions): ReadonlyArray => { - const rosterOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } + const rosterOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) rosterOptions.outputStore = outputStore const roster = defaultSubagents(rosterOptions) if (!rpi) return roster @@ -772,16 +774,11 @@ export const modeSubagents = ({ cwd, outputStore, rpi }: ModeSubagentOptions): R bash: delegateByName(roster, 'bash'), generalPurpose: delegateByName(roster, 'general-purpose'), } - const specialistOptions = - outputStore === undefined - ? { - cwd, - delegates, - } - : { - cwd, - outputStore, - delegates, - } + const specialistOptions: { + cwd: string + outputStore?: OutputStoreService + delegates: RpiSubagentOptions['delegates'] + } = { cwd, delegates } + if (outputStore !== undefined) specialistOptions.outputStore = outputStore return [...roster, ...rpiSubagents(specialistOptions)] } diff --git a/packages/fold-agent/src/Mode/Subagents.ts b/packages/fold-agent/src/Mode/Subagents.ts index 87dad5f..36946de 100644 --- a/packages/fold-agent/src/Mode/Subagents.ts +++ b/packages/fold-agent/src/Mode/Subagents.ts @@ -153,11 +153,11 @@ export const WEB_SEARCH_RESEARCHER_PROMPT: string = * binds its model by profile role, resolved through the session's profiles map at each dispatch. */ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): ReadonlyArray => { - const toolOptions = outputStore === undefined ? { cwd } : { cwd, outputStore } + const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) toolOptions.outputStore = outputStore const coding = codingTools(toolOptions) const skills = skillTool(skillsFromDisk({ cwd })) const web = webTools() - const bashOptions = toolOptions const bash = defineSubagent({ name: 'bash', @@ -165,7 +165,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Run shell commands (builds, tests, git, rg searches) and report the commands, exit status, and ' + 'the output that matters. Use it to execute something without spending your own context on raw output.', systemPrompt: BASH_SUBAGENT_PROMPT, - tools: [bashTool(bashOptions)], + tools: [bashTool(toolOptions)], model: 'fast', }) @@ -177,7 +177,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Locate code and explain how it works, returning a structured report with file:line references. ' + 'Use it for "where is X" and "how does Y work" questions that would otherwise require reading many files.', systemPrompt: [RESEARCHER_SUBAGENT_PROMPT, AST_GREP_OUTLINE_GUIDANCE], - tools: [readTool({ cwd }), bashTool(bashOptions), skills], + tools: [readTool({ cwd }), bashTool(toolOptions), skills], model: 'fast', }) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index f5fb8df..fc7e535 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -17,6 +17,8 @@ import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stre import { jsonlEventLog } from '../EventLog/JsonlDescriptor' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' +type Mutable = { -readonly [Key in keyof Value]: Value[Key] } + /** Options shared by the layout helpers. */ export type SessionLayoutOptions = { /** The project working directory the sessions belong to. Defaults to `process.cwd()`. */ @@ -335,12 +337,10 @@ export const listSessionSummaries = ( if (isCacheHit(cached, ref)) { // Explicitly construct to ensure size conforms to SessionLogRef's optional semantics. const summary = cached.summary - const sessionRef = - ref.size === undefined - ? { sessionId: summary.sessionId, path: ref.path, mtimeMs: ref.mtimeMs } - : { sessionId: summary.sessionId, path: ref.path, mtimeMs: ref.mtimeMs, size: ref.size } - const cachedSummary = { - ...sessionRef, + const cachedSummary: Mutable = { + sessionId: summary.sessionId, + path: ref.path, + mtimeMs: ref.mtimeMs, title: summary.title, status: summary.status, turns: summary.turns, @@ -352,6 +352,7 @@ export const listSessionSummaries = ( rpi: summary.rpi, profile: summary.profile, } + if (ref.size !== undefined) cachedSummary.size = ref.size return Effect.succeed(cachedSummary) } return loadSessionSummary(ref).pipe( diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index cec0fe0..531dc84 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -30,8 +30,9 @@ const resolveExaUrl = (options?: WebSearchToolOptions): string => { const resolveParallelHeaders = (options?: WebSearchToolOptions): Record => { const apiKey = options?.parallelApiKey ?? resolveEnv(options, 'PARALLEL_API_KEY') - if (apiKey === undefined || apiKey.length === 0) return { 'User-Agent': 'fold/1.0' } - return { 'User-Agent': 'fold/1.0', Authorization: `Bearer ${apiKey}` } + const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'fold/1.0' } + if (apiKey !== undefined && apiKey.length > 0) headers.Authorization = `Bearer ${apiKey}` + return headers } const checksum = (text: string): number => { diff --git a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts index 792ff84..3482900 100644 --- a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts @@ -93,10 +93,11 @@ const profileBindings = (profile: ProfileConfig): ReadonlyArray => /** Substitute one profile's roles as the config's active roles (what --profile does at launch). */ const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfig => { - const roles = - profile.orchestrator === undefined - ? { smart: profile.smart, fast: profile.fast } - : { smart: profile.smart, fast: profile.fast, orchestrator: profile.orchestrator } + const roles: { smart: RoleBinding; fast: RoleBinding; orchestrator?: RoleBinding } = { + smart: profile.smart, + fast: profile.fast, + } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator return { ...config, roles } } diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 46a9e34..731d027 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -406,11 +406,9 @@ const launchTui = (options: CliSessionOptions, catalog: ReadonlyArray import('@opentui/solid/preload')) const module = yield* Effect.promise(() => import('./tui/Shell')) - const tui = - prompt === undefined - ? module.runTui({ ...options, catalog }) - : module.runTui({ ...options, catalog, prompt }) - yield* tui.pipe( + const tuiOptions: Mutable[0]> = { ...options, catalog } + if (prompt !== undefined) tuiOptions.prompt = prompt + yield* module.runTui(tuiOptions).pipe( Effect.catchTags({ TuiRequiresTtyError: () => printFailure( @@ -455,7 +453,9 @@ const sessions = Command.make( Effect.gen(function* () { const cwd = optionValue(input.cwd) ?? process.cwd() const foldHome = optionValue(input.foldHome) - const sessions = yield* listSessionLogs(foldHome === undefined ? { cwd } : { cwd, foldHome }) + const sessionOptions: Mutable[0]>> = { cwd } + if (foldHome !== undefined) sessionOptions.foldHome = foldHome + const sessions = yield* listSessionLogs(sessionOptions) if (sessions.length === 0) { yield* Console.log(`No fold sessions for ${cwd}`) return @@ -525,10 +525,10 @@ const config = Command.make('config').pipe( if (apiKey !== undefined) provider.apiKey = apiKey if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv if (model !== undefined) provider.model = model - yield* configureProvider(provider, foldHome === undefined ? {} : { foldHome }) - yield* Console.log( - `Saved provider "${input.name}" in ${configPathFor(foldHome === undefined ? {} : { foldHome })}`, - ) + const configOptions: Mutable[1]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + yield* configureProvider(provider, configOptions) + yield* Console.log(`Saved provider "${input.name}" in ${configPathFor(configOptions)}`) }), ).pipe( Command.withDescription('Add or replace an Anthropic/OpenAI-compatible URL and credential'), @@ -545,7 +545,9 @@ const config = Command.make('config').pipe( Command.make('init', { foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - const result = yield* configInit(foldHome === undefined ? {} : { foldHome }) + const configOptions: Mutable[0]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + const result = yield* configInit(configOptions) yield* Console.log(`${result.createdConfig ? 'Created' : 'Found'} ${result.configPath}`) yield* Console.log(`${result.createdAuth ? 'Created' : 'Found'} ${result.authPath}`) yield* Console.log(`Wrote ${result.schemaPath}`) @@ -555,8 +557,10 @@ const config = Command.make('config').pipe( Command.make('validate', { foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - yield* loadFoldConfig(foldHome === undefined ? {} : { foldHome }) - yield* Console.log(`Valid ${configPathFor(foldHome === undefined ? {} : { foldHome })}`) + const configOptions: Mutable[0]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + yield* loadFoldConfig(configOptions) + yield* Console.log(`Valid ${configPathFor(configOptions)}`) }), ).pipe(Command.withDescription('Validate ~/.fold/config.jsonc')), ]), diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index 755e6c2..5edd9df 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -115,10 +115,9 @@ const openSession = ( ): Effect.Effect => Effect.gen(function* () { const session = yield* openSessionFor(options) - const logPath = - options.foldHome === undefined - ? sessionLogPathFor(session.sessionId, { cwd: options.cwd }) - : sessionLogPathFor(session.sessionId, { cwd: options.cwd, foldHome: options.foldHome }) + const logOptions: Mutable[1]>> = { cwd: options.cwd } + if (options.foldHome !== undefined) logOptions.foldHome = options.foldHome + const logPath = sessionLogPathFor(session.sessionId, logOptions) return { session, @@ -280,11 +279,14 @@ const withProcessSignals = ( * absent), and the regenerated `config.schema.json` + `FOLD_INFO.md`. Never fails a run - a broken * home surfaces as the launch's own config error moments later. */ -const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => - bootstrapFoldHome(options.foldHome === undefined ? {} : { foldHome: options.foldHome }).pipe( +const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => { + const bootstrapOptions: Mutable[0]>> = {} + if (options.foldHome !== undefined) bootstrapOptions.foldHome = options.foldHome + return bootstrapFoldHome(bootstrapOptions).pipe( Effect.asVoid, Effect.catchCause(() => Effect.void), ) +} const forkStartupEnsures = ( options: CliSessionOptions, diff --git a/packages/fold-cli/src/tui/ActivityIndicator.tsx b/packages/fold-cli/src/tui/ActivityIndicator.tsx index 9d90e0f..f0053d9 100644 --- a/packages/fold-cli/src/tui/ActivityIndicator.tsx +++ b/packages/fold-cli/src/tui/ActivityIndicator.tsx @@ -1,9 +1,11 @@ /** @jsxImportSource @opentui/solid */ +import type { TextProps } from '@opentui/solid' import { createMemo, createSignal, onCleanup } from 'solid-js' import { theme } from './ThemeState' export type ActivityState = 'ready' | 'running' | 'compacting' | 'stopped' | 'error' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } const presentation = (state: ActivityState, frame: number): { readonly glyph: string; readonly color: string } => { switch (state) { @@ -38,17 +40,14 @@ export const ActivityIndicator = (props: { onCleanup(() => clearInterval(timer)) const value = createMemo(() => presentation(props.state, frame())) - if (props.width === undefined) { - return ( - - {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} - - ) - } + const textProps = createMemo(() => { + const text: Mutable> = { + fg: value().color, + wrapMode: 'none', + } + if (props.width !== undefined) text.width = props.width + return text + }) - return ( - - {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} - - ) + return {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} } diff --git a/packages/fold-cli/src/tui/HostedTuiSession.ts b/packages/fold-cli/src/tui/HostedTuiSession.ts index c17c205..97755ef 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -189,10 +189,13 @@ export const makeHostedTuiSession = ( if (selection._tag === 'profile') { switchOptions.profile = selection.profile } else { - switchOptions.modelSelection = - selection.reasoning === undefined - ? { provider: selection.provider, model: selection.model } - : { provider: selection.provider, model: selection.model, reasoning: selection.reasoning } + const modelSelection: Mutable[1]['modelSelection']>> = + { + provider: selection.provider, + model: selection.model, + } + if (selection.reasoning !== undefined) modelSelection.reasoning = selection.reasoning + switchOptions.modelSelection = modelSelection } run( switchSessionMode(session, switchOptions).pipe( diff --git a/packages/fold-cli/src/tui/LaunchRequests.ts b/packages/fold-cli/src/tui/LaunchRequests.ts index b97168e..739e0c7 100644 --- a/packages/fold-cli/src/tui/LaunchRequests.ts +++ b/packages/fold-cli/src/tui/LaunchRequests.ts @@ -2,20 +2,25 @@ import type { NewSessionRequest } from './NewSessionModal' import type { SessionRow } from './SessionListProjection' import type { TuiOptions } from './TuiSessionOptions' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + /** Build a fresh launch request without carrying process-level model, profile, or mode choices across sessions. */ export const requestToLaunchOptions = (options: TuiOptions, request: NewSessionRequest): TuiOptions => { const { profile: _profile, modelSelection: _modelSelection, mode: _mode, ...base } = options + const launch: Mutable = { ...base, cwd: request.cwd } if (request._tag === 'profile') { - return request.profile === 'default' - ? { ...base, cwd: request.cwd } - : { ...base, cwd: request.cwd, profile: request.profile } + if (request.profile !== 'default') launch.profile = request.profile + return launch } - const modelSelection = - request.reasoning === undefined - ? { provider: request.provider, model: request.model } - : { provider: request.provider, model: request.model, reasoning: request.reasoning } - return { ...base, cwd: request.cwd, modelSelection, mode: request.mode } + const modelSelection: Mutable> = { + provider: request.provider, + model: request.model, + } + if (request.reasoning !== undefined) modelSelection.reasoning = request.reasoning + launch.modelSelection = modelSelection + launch.mode = request.mode + return launch } /** Resume with the durable session's model intent instead of the process's current model selection. */ @@ -25,22 +30,20 @@ export const sessionToLaunchOptions = ( ): TuiOptions => { const { profile: _profile, modelSelection: _modelSelection, mode: _mode, ...base } = options const mode = session.mode === 'rlm' ? 'rlm' : 'default' - if (session.profile !== null && session.profile !== 'default') return { ...base, profile: session.profile, mode } + const launch: Mutable = { ...base, mode } + if (session.profile !== null && session.profile !== 'default') { + launch.profile = session.profile + return launch + } const model = session.model - if (model === null) return { ...base, mode } + if (model === null) return launch - const modelSelection = - model.role === null || model.role === 'inherit' - ? { - provider: model.providerId, - model: model.modelId, - reasoning: model.requestedReasoningLevel, - } - : { - provider: model.providerId, - model: model.modelId, - reasoning: model.requestedReasoningLevel, - role: model.role, - } - return { ...base, mode, modelSelection } + const modelSelection: Mutable> = { + provider: model.providerId, + model: model.modelId, + reasoning: model.requestedReasoningLevel, + } + if (model.role !== null && model.role !== 'inherit') modelSelection.role = model.role + launch.modelSelection = modelSelection + return launch } diff --git a/packages/fold-cli/src/tui/ModelSelectionState.ts b/packages/fold-cli/src/tui/ModelSelectionState.ts index cc17c34..6326e2a 100644 --- a/packages/fold-cli/src/tui/ModelSelectionState.ts +++ b/packages/fold-cli/src/tui/ModelSelectionState.ts @@ -1,6 +1,8 @@ import type { ConfiguredModelSelection, ModelConfiguration, ProfileModeName } from '@humanlayer/fold-agent' import type { ReasoningLevel } from '@humanlayer/fold-core' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + export type ModelSelectionContext = 'active' | 'new-session' export type ModelSelectionRequest = | { readonly _tag: 'profile'; readonly profile: string; readonly mode?: ProfileModeName } @@ -23,12 +25,17 @@ export type ModelPickerState = | { readonly _tag: 'mode'; readonly selection: StagedModelSelection; readonly reasoning?: ReasoningLevel } export type ModelPickerChoice = { readonly id: string; readonly label: string; readonly detail: string } -export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => - request._tag === 'profile' - ? request - : request.reasoning === undefined - ? { _tag: 'direct', provider: request.provider, model: request.model } - : { _tag: 'direct', provider: request.provider, model: request.model, reasoning: request.reasoning } +export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => { + if (request._tag === 'profile') return request + + const selection: Mutable> = { + _tag: 'direct', + provider: request.provider, + model: request.model, + } + if (request.reasoning !== undefined) selection.reasoning = request.reasoning + return selection +} const REASONING_LEVELS: ReadonlyArray<{ id: ReasoningLevel; label: string; detail: string }> = [ { id: 'off', label: 'Off', detail: 'No extended thinking' }, @@ -106,20 +113,25 @@ export const advanceModelPicker = ( return { _tag: 'model', provider: choice } case 'model': return { _tag: 'reasoning', selection: { _tag: 'direct', provider: state.provider, model: choice } } - case 'reasoning': - return choice === 'off' - ? { _tag: 'mode', selection: state.selection } - : { _tag: 'mode', selection: state.selection, reasoning: toReasoningLevel(choice) } - case 'mode': - return state.selection._tag === 'profile' - ? { ...state.selection, mode: choice === 'rlm' ? 'rlm' : 'default' } - : state.reasoning === undefined - ? { ...state.selection, mode: choice === 'rlm' ? 'rlm' : 'default' } - : { - ...state.selection, - reasoning: state.reasoning, - mode: choice === 'rlm' ? 'rlm' : 'default', - } + case 'reasoning': { + const next: Mutable> = { + _tag: 'mode', + selection: state.selection, + } + if (choice !== 'off') next.reasoning = toReasoningLevel(choice) + return next + } + case 'mode': { + const mode = choice === 'rlm' ? 'rlm' : 'default' + if (state.selection._tag === 'profile') return { ...state.selection, mode } + + const selection: Mutable> = { + ...state.selection, + mode, + } + if (state.reasoning !== undefined) selection.reasoning = state.reasoning + return selection + } } } export const retreatModelPicker = (state: ModelPickerState): ModelPickerState | null => { diff --git a/packages/fold-cli/src/tui/NewSessionModal.tsx b/packages/fold-cli/src/tui/NewSessionModal.tsx index 002956d..a552a34 100644 --- a/packages/fold-cli/src/tui/NewSessionModal.tsx +++ b/packages/fold-cli/src/tui/NewSessionModal.tsx @@ -26,6 +26,8 @@ export type NewSessionRequest = { readonly cwd: string } & ( } ) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + const expandHome = (value: string): string => value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value @@ -195,23 +197,14 @@ export const NewSessionModal = (props: { if (selection._tag === 'profile') { props.onSubmit({ _tag: 'profile', profile: selection.profile, cwd: cwd() }) } else if (selection.mode !== undefined) { - const request = - selection.reasoning === undefined - ? { - _tag: 'direct' as const, - provider: selection.provider, - model: selection.model, - mode: selection.mode, - cwd: cwd(), - } - : { - _tag: 'direct' as const, - provider: selection.provider, - model: selection.model, - reasoning: selection.reasoning, - mode: selection.mode, - cwd: cwd(), - } + const request: Mutable> = { + _tag: 'direct', + provider: selection.provider, + model: selection.model, + mode: selection.mode, + cwd: cwd(), + } + if (selection.reasoning !== undefined) request.reasoning = selection.reasoning props.onSubmit(request) } }} diff --git a/packages/fold-cli/src/tui/ProviderConfigState.ts b/packages/fold-cli/src/tui/ProviderConfigState.ts index 97e2ba0..e23ce0f 100644 --- a/packages/fold-cli/src/tui/ProviderConfigState.ts +++ b/packages/fold-cli/src/tui/ProviderConfigState.ts @@ -100,14 +100,15 @@ export const providerFormFor = (configuration: ModelConfiguration, name: string) const provider = configuration.providers.find((candidate) => candidate.name === name) if (provider === undefined) return emptyProviderForm() const fallback = defaults(provider.kind) - const form = { + const form: Mutable = { kind: provider.kind, name: provider.name, baseUrl: provider.baseUrl ?? fallback.baseUrl, apiKey: '', model: provider.models[0] ?? fallback.model, } - return provider.apiKeyEnv === null ? form : { ...form, apiKeyEnv: provider.apiKeyEnv } + if (provider.apiKeyEnv !== null) form.apiKeyEnv = provider.apiKeyEnv + return form } const nextKinds: Record = { diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index 7fe0aa2..c76913d 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -82,15 +82,15 @@ export const makeTuiSessionWorkspace = (options: { const cwds = new Set([options.tui.cwd]) const cwdBySession = new Map() const loadSummaries = Effect.suspend(() => - Effect.forEach([...cwds], (cwd) => - listSessionSummaries( - options.tui.foldHome === undefined ? { cwd } : { cwd, foldHome: options.tui.foldHome }, - ).pipe( + Effect.forEach([...cwds], (cwd) => { + const summaryOptions: Mutable[0]>> = { cwd } + if (options.tui.foldHome !== undefined) summaryOptions.foldHome = options.tui.foldHome + return listSessionSummaries(summaryOptions).pipe( Effect.tap((rows) => Effect.sync(() => rows.forEach((row) => cwdBySession.set(row.sessionId, cwd))), ), - ), - ).pipe( + ) + }).pipe( Effect.map((groups) => { const byId = new Map(groups.flat().map((summary) => [summary.sessionId, summary])) return [...byId.values()] @@ -266,10 +266,9 @@ export const makeTuiSessionWorkspace = (options: { Effect.gen(function* () { const cwd = host.get(sessionId)?.cwd ?? cwdBySession.get(sessionId) ?? options.tui.cwd yield* host.close(sessionId) - const result = yield* deleteSession( - sessionId, - options.tui.foldHome === undefined ? { cwd } : { cwd, foldHome: options.tui.foldHome }, - ) + const deleteOptions: Mutable[1]>> = { cwd } + if (options.tui.foldHome !== undefined) deleteOptions.foldHome = options.tui.foldHome + const result = yield* deleteSession(sessionId, deleteOptions) setSummaries(yield* loadSummaries) setNotice( !result.deleted diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index 3fa1110..3bb9dd2 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -51,6 +51,7 @@ type ResponsesPayload = Omit +type MutableCodexRetryOptions = { -readonly [Key in keyof CodexRetryOptions]: CodexRetryOptions[Key] } // The exact shape the provider emits for a prompt system message: a message item with plain string // content. Anything else (array content, other roles, non-message items) ends the leading run. @@ -247,9 +248,9 @@ export const makeCodexLanguageModel = ( const httpContext = yield* Layer.build(FetchHttpClient.layer) const baseClient = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeCodexAuth(options.store === undefined ? {} : { store: options.store }).pipe( - Effect.provideService(HttpClient.HttpClient, baseClient), - ) + const authOptions: { store?: CodexAuthStore } = {} + if (options.store !== undefined) authOptions.store = options.store + const auth = yield* makeCodexAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, baseClient)) // retryTransient sits below the auth wrapper: transport retries reuse the injected headers and never // re-enter (or retry) the auth path itself. Status responses are mapped to AiError above this seam, @@ -270,10 +271,8 @@ export const makeCodexLanguageModel = ( ) const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) - const hardening = - options.onStreamRetry === undefined - ? { ...defaultCodexHardening, ...options.hardening } - : { ...defaultCodexHardening, ...options.hardening, onStreamRetry: options.onStreamRetry } + const hardening: MutableCodexRetryOptions = { ...defaultCodexHardening, ...options.hardening } + if (options.onStreamRetry !== undefined) hardening.onStreamRetry = options.onStreamRetry const codexClient = decorateCodexClient(stockClient, hardening) const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index 0924b2c..10d9795 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -64,6 +64,18 @@ const TokenResponse = Schema.Struct({ }) type TokenResponse = typeof TokenResponse.Type +type CodexTokenDataInput = { + type: 'oauth' + access: string + refresh: string + expires: number + accountId?: string +} +type CodexAuthErrorInput = { + reason: CodexAuthError['reason'] + message: string + cause?: unknown +} // --- JWT account-id extraction (clanka port) -------------------------------------------------------- @@ -156,21 +168,14 @@ const extractAccountId = (token: TokenResponse): string | undefined => { const toTokenData = (token: TokenResponse): Effect.Effect => Effect.map(Clock.currentTimeMillis, (now) => { const accountId = extractAccountId(token) - - return accountId === undefined - ? new CodexTokenData({ - type: 'oauth', - access: token.access_token, - refresh: token.refresh_token, - expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, - }) - : new CodexTokenData({ - type: 'oauth', - access: token.access_token, - refresh: token.refresh_token, - expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, - accountId, - }) + const data: CodexTokenDataInput = { + type: 'oauth', + access: token.access_token, + refresh: token.refresh_token, + expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, + } + if (accountId !== undefined) data.accountId = accountId + return new CodexTokenData(data) }) /** Carry an account id a token response omitted forward from the previous credential. */ @@ -202,25 +207,19 @@ export const makeIssuerHttpClient = (client: HttpClient.HttpClient): HttpClient. }), ) -const refreshError = (message: string, cause?: unknown) => - cause === undefined - ? new CodexAuthError({ reason: 'RefreshFailed', message }) - : new CodexAuthError({ reason: 'RefreshFailed', message, cause }) - -const exchangeError = (message: string, cause?: unknown) => - cause === undefined - ? new CodexAuthError({ reason: 'TokenExchangeFailed', message }) - : new CodexAuthError({ reason: 'TokenExchangeFailed', message, cause }) - -const deviceFlowError = (message: string, cause?: unknown) => - cause === undefined - ? new CodexAuthError({ reason: 'DeviceFlowFailed', message }) - : new CodexAuthError({ reason: 'DeviceFlowFailed', message, cause }) - -const browserFlowError = (message: string, cause?: unknown) => - cause === undefined - ? new CodexAuthError({ reason: 'BrowserFlowFailed', message }) - : new CodexAuthError({ reason: 'BrowserFlowFailed', message, cause }) +const authError = (reason: CodexAuthError['reason'], message: string, cause?: unknown): CodexAuthError => { + const options: CodexAuthErrorInput = { reason, message } + if (cause !== undefined) options.cause = cause + return new CodexAuthError(options) +} + +const refreshError = (message: string, cause?: unknown) => authError('RefreshFailed', message, cause) + +const exchangeError = (message: string, cause?: unknown) => authError('TokenExchangeFailed', message, cause) + +const deviceFlowError = (message: string, cause?: unknown) => authError('DeviceFlowFailed', message, cause) + +const browserFlowError = (message: string, cause?: unknown) => authError('BrowserFlowFailed', message, cause) /** Refresh an access token through the issuer. The client must come from {@link makeIssuerHttpClient}. */ export const refreshAccessToken = Effect.fn('fold.codexAuth.refreshAccessToken')(function* ( diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 2aa73b4..8564b8c 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -59,15 +59,16 @@ const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const -const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => - cacheBreakpoint - ? Prompt.systemMessage({ - content, - options: { - anthropic: { cacheControl: anthropicEphemeralCacheControl }, - }, - }) - : Prompt.systemMessage({ content }) +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => { + const input: Mutable[0]> = { content } + if (cacheBreakpoint) { + input.options = { anthropic: { cacheControl: anthropicEphemeralCacheControl } } + } + + return Prompt.systemMessage(input) +} const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) @@ -261,29 +262,19 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, }) - const compactionInput = - postCompactionInstructions === null - ? { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - } - : { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - postCompactionInstructions, - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - } + const compactionInput: Mutable[0]> = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + compactionId: yield* ids.makeCompactionId, + prompt: planned.success.prompt, + summary: planned.success.summary, + replacesThroughSeq: planned.success.replacesThroughSeq, + tokensBefore: planned.success.tokensBefore, + } + if (postCompactionInstructions !== null) { + compactionInput.postCompactionInstructions = postCompactionInstructions + } const entry = yield* appendToEventLog(LogEntryInputs['compaction'](compactionInput)) if (Predicate.isTagged(entry, 'compaction')) return entry @@ -565,31 +556,20 @@ export const liveAgentRuntimeLayer: Layer.Layer< // prompt block set once for the starting model; both are recorded durably. const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - const agentStartedInput = - input.promptCacheKey == null - ? { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - } - : { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - promptCacheKey: input.promptCacheKey, - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - } + const agentStartedInput: Mutable[0]> = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + mode: input.mode, + model: input.model, + tools: resolvedToolset.names, + skill: input.skill, + fork: input.fork, + agentType: input.agentType, + } + if (input.promptCacheKey != null) { + agentStartedInput.promptCacheKey = input.promptCacheKey + } const entry = yield* appendToEventLog(LogEntryInputs['agent_started'](agentStartedInput)) // A fork appends no leading system message: its projection folds the forked-from agent's diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 052b590..4742434 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -42,6 +42,8 @@ import { makeToolsetResolver } from '../ToolRuntime/ToolsetResolverLayer' import type { FoldModel } from './ModelDescriptor' import type { RealizedFoldTool, FoldTool } from './ToolDefinition' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + const anthropicDecoderModelFor = (modelId: string): string | null => { const id = modelId.toLowerCase() if (id.includes('opus')) return id === 'claude-opus-4-6' ? null : 'claude-opus-4-6' @@ -101,26 +103,22 @@ export const languageModelLayerFor = (model: FoldModel): Layer.Layer { - const clientOptions = - connection.baseUrl === null - ? { apiKey: connection.apiKey } - : { apiKey: connection.apiKey, apiUrl: connection.baseUrl } + const clientOptions: Mutable[0]> = { apiKey: connection.apiKey } + if (connection.baseUrl !== null) { + clientOptions.apiUrl = connection.baseUrl + } const clientLayer = OpenAiClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return OpenAiLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) }, anthropic: (connection) => { - const clientOptions = - connection.baseUrl === null - ? { - apiKey: connection.apiKey, - transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - } - : { - apiKey: connection.apiKey, - transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - apiUrl: connection.baseUrl, - } + const clientOptions: Mutable[0]> = { + apiKey: connection.apiKey, + transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), + } + if (connection.baseUrl !== null) { + clientOptions.apiUrl = connection.baseUrl + } const clientLayer = AnthropicClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return AnthropicLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 477f75a..4f080b6 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -106,6 +106,8 @@ import type { FoldModel } from './ModelDescriptor' import { AgentProvisioner, makeAgentProvisioner, validateToolNames } from './Provisioning' import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** Options for {@link startSession}. */ export type StartSessionOptions = { readonly agent: AgentDefinition @@ -959,40 +961,22 @@ export const startSession = ( Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) - const meta = - options.agent.name === undefined ? { ...options.meta } : { ...options.meta, agentName: options.agent.name } - const startInput: StartSessionInput = - options.agent.promptCacheKey === undefined - ? options.sessionId === undefined - ? { - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - } - : { - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - sessionId: options.sessionId, - } - : options.sessionId === undefined - ? { - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - promptCacheKey: options.agent.promptCacheKey, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - } - : { - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - promptCacheKey: options.agent.promptCacheKey, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - sessionId: options.sessionId, - } + const meta: Mutable> = { ...options.meta } + if (options.agent.name !== undefined) { + meta.agentName = options.agent.name + } + const startInput: Mutable = { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + } + if (options.agent.promptCacheKey !== undefined) { + startInput.promptCacheKey = options.agent.promptCacheKey + } + if (options.sessionId !== undefined) { + startInput.sessionId = options.sessionId + } const started = yield* graph.session.start(startInput).pipe(Effect.orDie) diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 5249bd9..33a35bd 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -46,6 +46,27 @@ export type ToolHandlerServices = type PlatformToolServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +type ToolDependency = + | typeof ToolState + | typeof ToolEvents + | typeof StopController + | typeof CurrentAgent + | typeof CurrentToolCall + | typeof InterruptNote + | typeof Subagents + | typeof FileSystem.FileSystem + | typeof Path.Path + | typeof ChildProcessSpawner.ChildProcessSpawner + +type ToolOptionsBuilder = { + description: string + parameters?: Params + success: Success | typeof Schema.Undefined + failure?: Failure + failureMode: 'return' + dependencies: Array +} + /** Neutral platform services used by filesystem and process-backed tools. */ export const platformToolDependencies = [ FileSystem.FileSystem, @@ -127,10 +148,10 @@ export const defineTool = < >( options: DefineToolOptions, ): FoldTool => { - const toolOptions = { + const toolOptions: ToolOptionsBuilder = { description: options.description, success: options.success ?? Schema.Undefined, - failureMode: 'return' as const, + failureMode: 'return', // Every tool may use the ambient per-call services; declaring them here keeps handler `R` // honest while the runtime provides all of them around each execution. dependencies: [ @@ -145,16 +166,13 @@ export const defineTool = < ...(options.dependencies ?? []), ], } - const tool = Tool.make( - options.name, - options.parameters === undefined - ? options.failure === undefined - ? toolOptions - : { ...toolOptions, failure: options.failure } - : options.failure === undefined - ? { ...toolOptions, parameters: options.parameters } - : { ...toolOptions, parameters: options.parameters, failure: options.failure }, - ).annotate(Tool.Strict, false) + if (options.parameters !== undefined) { + toolOptions.parameters = options.parameters + } + if (options.failure !== undefined) { + toolOptions.failure = options.failure + } + const tool = Tool.make(options.name, toolOptions).annotate(Tool.Strict, false) // asVoid yields the undefined value at runtime, which is exactly what Schema.Undefined encodes. const handlerWithDependencies = diff --git a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts index ad555f2..53c9c55 100644 --- a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts +++ b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts @@ -11,10 +11,20 @@ import { const PersistedRecord = Schema.Record(Schema.String, Schema.Unknown) -const corruptEntry = (message: string, cause: unknown, seq?: number) => - seq === undefined - ? new EventLogCorruptEntryError({ operation: 'entries', message, cause }) - : new EventLogCorruptEntryError({ operation: 'entries', message, seq, cause }) +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +const corruptEntry = (message: string, cause: unknown, seq?: number) => { + const input: Mutable[0]> = { + operation: 'entries', + message, + cause, + } + if (seq !== undefined) { + input.seq = seq + } + + return new EventLogCorruptEntryError(input) +} /** * Decode one persisted Fold event by its wire-format version. diff --git a/packages/fold-core/src/Model/ModelRequestSettings.ts b/packages/fold-core/src/Model/ModelRequestSettings.ts index 2ef83c9..41c7fdb 100644 --- a/packages/fold-core/src/Model/ModelRequestSettings.ts +++ b/packages/fold-core/src/Model/ModelRequestSettings.ts @@ -23,6 +23,13 @@ const OpenAiReasoning = Data.taggedEnum() const CodexReasoning = Data.taggedEnum() const AnthropicThinking = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +// The provider's Config omits this Responses API field even though the request encoder accepts it. +type OpenAiConfigBuilder = Mutable[1]> & { + prompt_cache_key?: string +} + /** * Map one reasoning level onto the OpenAI effort scale. `off` disables reasoning config entirely * (provider default applies); every other level passes straight through - the wire scale includes @@ -155,10 +162,13 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort }) => ({ reasoning: { effort } }), }) - const config = - promptCacheKey === null - ? { model: model.modelId, ...reasoning } - : { model: model.modelId, prompt_cache_key: promptCacheKey, ...reasoning } + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } @@ -170,10 +180,13 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), }) - const config = - promptCacheKey === null - ? { model: model.modelId, ...reasoning } - : { model: model.modelId, prompt_cache_key: promptCacheKey, ...reasoning } + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 3f16851..8085470 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -73,6 +73,8 @@ export type ProjectedMessage = const ProjectedMessage = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** Tool-owned key/value state for one agent namespace, built by folding tool_state entries in log order. */ export type ToolStateProjection = Readonly> @@ -387,24 +389,17 @@ export const messagesForAgent = ( } if (compaction !== null) { - const summary = - compaction.postCompactionInstructions === undefined - ? ProjectedMessage['compaction-summary']({ - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - tokensBefore: compaction.tokensBefore, - }) - : ProjectedMessage['compaction-summary']({ - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - postCompactionInstructions: compaction.postCompactionInstructions, - tokensBefore: compaction.tokensBefore, - }) - projected.push(summary) + const summaryInput: Mutable[0]> = { + sourceSeq: compaction.seq, + compactionId: compaction.compactionId, + replacesThroughSeq: compaction.replacesThroughSeq, + summary: compaction.summary, + tokensBefore: compaction.tokensBefore, + } + if (compaction.postCompactionInstructions !== undefined) { + summaryInput.postCompactionInstructions = compaction.postCompactionInstructions + } + projected.push(ProjectedMessage['compaction-summary'](summaryInput)) } for (const entry of visibleEntries) { diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 5ad34f6..f750498 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -26,6 +26,8 @@ export type SubagentToolCapabilities = { readonly forkAgent?: ForkAgentDefinition } +type Mutable = { -readonly [Key in keyof T]: T[Key] } + const capabilitiesBySubagentTool = new WeakMap() /** Attach Fold's roster and fork behavior to any host-defined model-visible tool. */ @@ -207,7 +209,10 @@ export const subagentTool = ( }), }) - return options?.forkAgent === undefined - ? withSubagentCapabilities(tool, { agents }) - : withSubagentCapabilities(tool, { agents, ...options }) + const capabilities: Mutable = { agents } + if (options?.forkAgent !== undefined) { + capabilities.forkAgent = options.forkAgent + } + + return withSubagentCapabilities(tool, capabilities) } diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index d8503a9..c253eba 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -61,6 +61,8 @@ import type { const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** One agent's tools realized against the session-start contributions (once-per-value inits). */ export type RealizedAgentTools = { readonly tools: ReadonlyArray @@ -808,23 +810,13 @@ export const makeSubagents = ( : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) const agentLabel = `fork of ${shortAgentId(dispatcher.agentId)}` yield* interruptNote.set(interruptedSubagentNote(agentLabel, subagentId, 0)) - const fork = - input.forkAgentDefinitionId === null - ? input.history === undefined - ? { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq } - : { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq, history: input.history } - : input.history === undefined - ? { - fromAgentId: dispatcher.agentId, - atSeq: lastEntry.seq, - definitionId: input.forkAgentDefinitionId, - } - : { - fromAgentId: dispatcher.agentId, - atSeq: lastEntry.seq, - definitionId: input.forkAgentDefinitionId, - history: input.history, - } + const fork: Mutable = { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq } + if (input.forkAgentDefinitionId !== null) { + fork.definitionId = input.forkAgentDefinitionId + } + if (input.history !== undefined) { + fork.history = input.history + } return yield* runSubagentToResult({ subagentId, diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 10ce034..8f96ca6 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -44,6 +44,8 @@ type PreparedToolCall = const PreparedToolCall = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + type FinalToolOutput = { readonly result: unknown readonly isFailure: boolean @@ -118,23 +120,16 @@ const appendToolResultToEventLog = (input: { const eventLog = yield* EventLog const ids = yield* Ids const message = yield* encodedToolResultMessage(input) - const entryInput = - input.executedInput === undefined - ? { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - } - : { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - executedInput: input.executedInput, - } + const entryInput: Mutable[0]> = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + } + if (input.executedInput !== undefined) { + entryInput.executedInput = input.executedInput + } const entry = yield* eventLog.append(LogEntryInputs['tool-result'](entryInput)).pipe(Effect.orDie) @@ -475,36 +470,32 @@ const settlePreparedToolCall = (input: { output: handlerOutput, }) - if (valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params)) { - return { result: finalOutput.result, isFailure: finalOutput.isFailure } - } - - return { + const result: Mutable = { result: finalOutput.result, isFailure: finalOutput.isFailure, - executedInput: input.prepared.params, } + if (!valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params)) { + result.executedInput = input.prepared.params + } + + return result }) - const append = (result: ToolResultAppendInput) => - result.executedInput === undefined - ? appendToolResultToEventLog({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - result: result.result, - isFailure: result.isFailure, - }) - : appendToolResultToEventLog({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - result: result.result, - isFailure: result.isFailure, - executedInput: result.executedInput, - }) + const append = (result: ToolResultAppendInput) => { + const appendInput: Mutable[0]> = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + result: result.result, + isFailure: result.isFailure, + } + if (result.executedInput !== undefined) { + appendInput.executedInput = result.executedInput + } + + return appendToolResultToEventLog(appendInput) + } const runnable = output.pipe( Effect.provideService(ToolState, toolState), diff --git a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts index 2aebe8b..c788f95 100644 --- a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts +++ b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts @@ -11,35 +11,41 @@ import { decodeStoredLogEntry, } from '../../src/index' -const sessionStartedEntry = (version?: number) => - version === undefined - ? { - _tag: 'session_started', - seq: 0, - eventId: EventId.create(), - ts: 1, - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: '/tmp/fold', - sessionId: SessionId.create(), - rootAgentId: AgentId.create(), - meta: {}, - } - : { - _tag: 'session_started', - seq: 0, - eventId: EventId.create(), - ts: 1, - version, - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: '/tmp/fold', - sessionId: SessionId.create(), - rootAgentId: AgentId.create(), - meta: {}, - } +type SessionStartedEntryBuilder = { + _tag: 'session_started' + seq: number + eventId: ReturnType + ts: number + version?: number + agentId: null + parentAgentId: null + toolCallId: null + cwd: string + sessionId: ReturnType + rootAgentId: ReturnType + meta: Record +} + +const sessionStartedEntry = (version?: number) => { + const entry: SessionStartedEntryBuilder = { + _tag: 'session_started', + seq: 0, + eventId: EventId.create(), + ts: 1, + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: '/tmp/fold', + sessionId: SessionId.create(), + rootAgentId: AgentId.create(), + meta: {}, + } + if (version !== undefined) { + entry.version = version + } + + return entry +} const legacySessionTitleEntry = () => ({ _tag: 'session_title', diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index 622fc0b..c59a96d 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -29,6 +29,8 @@ import { import { gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' import { textTurn, toolCallTurn, type ScriptedTurn } from '../TestLayers/ScriptedLanguageModel' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** One engine operation the drive tool should perform on its next invocation. */ export type DriveInstruction = | { readonly op: 'dispatch'; readonly agent: string; readonly prompt: string; readonly skill?: string } @@ -110,9 +112,11 @@ export const makeDriveSession = (input: { systemPrompt: 'root', tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], }) - const session = yield* startSession( - input.profiles === undefined ? { agent } : { agent, profiles: input.profiles }, - ) + const startOptions: Mutable[0]> = { agent } + if (input.profiles !== undefined) { + startOptions.profiles = input.profiles + } + const session = yield* startSession(startOptions) /** Queue one instruction; the caller decides how to run the send (await, fork, ...). */ const queue = (instruction: DriveInstruction) => Ref.set(instructions, [instruction]) diff --git a/packages/fold-opencode/src/OpenCodeAuth.ts b/packages/fold-opencode/src/OpenCodeAuth.ts index 66a1fe3..1348a62 100644 --- a/packages/fold-opencode/src/OpenCodeAuth.ts +++ b/packages/fold-opencode/src/OpenCodeAuth.ts @@ -23,6 +23,13 @@ const Pending = Schema.Struct({ error: Schema.String }) const DeviceToken = Schema.Union([Token, Pending]) const User = Schema.Struct({ id: Schema.String, email: Schema.String }) const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) +type OpenCodeTokenMetadata = { + server: string + accountID: string + email: string + orgID?: string + orgName?: string +} export class OpenCodeAuthError extends Schema.TaggedError()('OpenCodeAuthError', { reason: Schema.Literals(['NotAuthenticated', 'AuthorizationFailed', 'RefreshFailed', 'StoreFailed']), @@ -75,20 +82,15 @@ const credential = (client: HttpClient.HttpClient, server: string, token: typeof ) const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] const now = yield* Clock.currentTimeMillis - const metadata = - org === undefined - ? { - server, - accountID: user.id, - email: user.email, - } - : { - server, - accountID: user.id, - email: user.email, - orgID: org.id, - orgName: org.name, - } + const metadata: OpenCodeTokenMetadata = { + server, + accountID: user.id, + email: user.email, + } + if (org !== undefined) { + metadata.orgID = org.id + metadata.orgName = org.name + } return new OpenCodeTokenData({ type: 'oauth', access: token.access_token, @@ -246,14 +248,15 @@ export const withOpenCodeAuth = (client: HttpClient.HttpClient, auth: OpenCodeAu client.pipe( HttpClient.mapRequestEffect((request) => auth.get.pipe( - Effect.map((token) => - request.pipe( + Effect.map((token) => { + const headers: Record = {} + const orgId = token.metadata?.orgID + if (orgId !== undefined) headers['x-org-id'] = orgId + return request.pipe( HttpClientRequest.bearerToken(token.access), - HttpClientRequest.setHeaders( - token.metadata?.orgID === undefined ? {} : { 'x-org-id': token.metadata.orgID }, - ), - ), - ), + HttpClientRequest.setHeaders(headers), + ) + }), Effect.mapError( (cause) => new HttpClientError.HttpClientError({ diff --git a/packages/fold-opencode/src/OpenCodeModel.ts b/packages/fold-opencode/src/OpenCodeModel.ts index 3ddba15..6ce64bc 100644 --- a/packages/fold-opencode/src/OpenCodeModel.ts +++ b/packages/fold-opencode/src/OpenCodeModel.ts @@ -110,14 +110,9 @@ export const makeOpenCodeLanguageModel = ( Effect.gen(function* () { const httpContext = yield* Layer.build(FetchHttpClient.layer) const http = Context.get(httpContext, HttpClient.HttpClient) - const authOptions = - options.store === undefined - ? options.consoleUrl === undefined - ? {} - : { server: options.consoleUrl } - : options.consoleUrl === undefined - ? { store: options.store } - : { store: options.store, server: options.consoleUrl } + const authOptions: { store?: OpenCodeAuthStore; server?: string } = {} + if (options.store !== undefined) authOptions.store = options.store + if (options.consoleUrl !== undefined) authOptions.server = options.consoleUrl const auth = yield* makeOpenCodeAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, http)) const authenticated = withOpenCodeAuth(http, auth) const requestedModel = options.model ?? DEFAULT_OPENCODE_MODEL_ID diff --git a/packages/fold-tui-theme/src/github/client.ts b/packages/fold-tui-theme/src/github/client.ts index 8c89ce4..6e2f61e 100644 --- a/packages/fold-tui-theme/src/github/client.ts +++ b/packages/fold-tui-theme/src/github/client.ts @@ -34,6 +34,8 @@ interface RawItem { pull_request?: unknown } +type MutableGhItem = { -readonly [Key in keyof GhItem]: GhItem[Key] } + const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback) const num = (v: unknown, fallback = 0): number => (typeof v === 'number' ? v : fallback) @@ -54,7 +56,7 @@ function parseLabels(v: unknown): Array { function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { const headRef = raw.head ? str(raw.head.ref) : '' const baseRef = raw.base ? str(raw.base.ref) : '' - const item: Omit = { + const item: MutableGhItem = { kind, number: num(raw.number), title: str(raw.title, '(untitled)'), @@ -71,8 +73,9 @@ function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { } // `exactOptionalPropertyTypes` forbids assigning `undefined` to an // optional prop, so only add the keys when their refs are non-empty. - if (headRef) return baseRef ? { ...item, headRef, baseRef } : { ...item, headRef } - return baseRef ? { ...item, baseRef } : item + if (headRef) item.headRef = headRef + if (baseRef) item.baseRef = baseRef + return item } /** Env vars first, then whatever `gh` is already logged in as. */ diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index 5e8003e..58d0245 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -54,8 +54,11 @@ const DeviceError = Schema.Struct({ error_description: Schema.optional(Schema.String), }) -const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown) => - cause === undefined ? new XaiAuthError({ reason, message }) : new XaiAuthError({ reason, message, cause }) +const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown): XaiAuthError => { + const options: { reason: XaiAuthError['reason']; message: string; cause?: unknown } = { reason, message } + if (cause !== undefined) options.cause = cause + return new XaiAuthError(options) +} const tokenData = (payload: typeof TokenResponse.Type, fallbackRefresh?: string) => Effect.map( diff --git a/packages/fold-xai/src/XaiModel.ts b/packages/fold-xai/src/XaiModel.ts index 0170485..1d0e134 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -76,9 +76,9 @@ export const makeXaiLanguageModel = ( Effect.gen(function* () { const httpContext = yield* Layer.build(FetchHttpClient.layer) const base = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeXaiAuth(options.store === undefined ? {} : { store: options.store }).pipe( - Effect.provideService(HttpClient.HttpClient, base), - ) + const authOptions: { store?: XaiAuthStore } = {} + if (options.store !== undefined) authOptions.store = options.store + const auth = yield* makeXaiAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, base)) const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl: options.apiUrl ?? XAI_API_URL })).pipe( Effect.provideService(HttpClient.HttpClient, withXaiAuth(base, auth)), ) diff --git a/scripts/build/binaries.ts b/scripts/build/binaries.ts index d92b73a..579a21e 100644 --- a/scripts/build/binaries.ts +++ b/scripts/build/binaries.ts @@ -46,17 +46,11 @@ for (const target of selected) { await mkdir(outdir, { recursive: true }) const bunTarget: Bun.CompileTarget = `bun-${os === 'windows' ? 'windows' : os}-${cpu}${variant.includes('baseline') ? '-baseline' : ''}${variant.includes('musl') ? '-musl' : ''}` const bunfs = os === 'windows' ? 'B:/~BUN/root/' : '/$bunfs/root/' - const define = - os === 'linux' - ? { - FOLD_VERSION: JSON.stringify(versionArg), - OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), - 'process.env.OPENTUI_LIBC': JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc'), - } - : { - FOLD_VERSION: JSON.stringify(versionArg), - OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), - } + const define: Record = { + FOLD_VERSION: JSON.stringify(versionArg), + OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), + } + if (os === 'linux') define['process.env.OPENTUI_LIBC'] = JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc') const result = await Bun.build({ entrypoints: [join(root, 'packages/fold-cli/src/cli.ts'), parserWorker], plugins: [createSolidTransformPlugin()], diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts index 1776422..e15413e 100644 --- a/scripts/build/packages.ts +++ b/scripts/build/packages.ts @@ -26,6 +26,8 @@ for (const name of libraries) { const outdir = join(dir, 'dist') await rm(outdir, { recursive: true, force: true }) await mkdir(outdir, { recursive: true }) + const define: Record = {} + if (name === 'fold-cli') define.FOLD_VERSION = JSON.stringify(version) const result = await Bun.build({ entrypoints: entries.map((entry) => join(dir, entry)), outdir, @@ -36,7 +38,7 @@ for (const name of libraries) { external: name === 'fold-cli' ? ['@opentui/core', '@opentui/core/*'] : [], sourcemap: 'external', plugins: name === 'fold-cli' ? [solidTransformPlugin] : [], - define: name === 'fold-cli' ? { FOLD_VERSION: JSON.stringify(version) } : {}, + define, }) if (!result.success) throw new AggregateError(result.logs, `Failed to build ${manifest.name}`) const buildConfig = join(dir, 'tsconfig.release.json') diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index e11ac40..3921f44 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -21,6 +21,19 @@ type PackageManifest = { exports: Record bin?: Record } +type NativePackageManifest = { + name: string + version: string + description: string + license: string + repository: typeof repository + preferUnplugged: boolean + os: Array + cpu: Array + libc?: Array + files: Array + publishConfig: { access: string } +} const rootManifest = await json<{ workspaces: { catalog: Record } }>(join(root, 'package.json')) const catalog = rootManifest.workspaces.catalog @@ -88,32 +101,19 @@ for (const target of targets) { await mkdir(dest, { recursive: true }) await cp(join(source, 'bin'), join(dest, 'bin'), { recursive: true }) const [os, cpu, variant] = target - const manifest = variant.includes('musl') - ? { - name, - version, - description: 'Platform binary for @humanlayer/fold', - license: 'MIT', - repository, - preferUnplugged: true, - os: [os === 'windows' ? 'win32' : os], - cpu: [cpu], - libc: ['musl'], - files: ['bin'], - publishConfig: { access: 'public' }, - } - : { - name, - version, - description: 'Platform binary for @humanlayer/fold', - license: 'MIT', - repository, - preferUnplugged: true, - os: [os === 'windows' ? 'win32' : os], - cpu: [cpu], - files: ['bin'], - publishConfig: { access: 'public' }, - } + const manifest: NativePackageManifest = { + name, + version, + description: 'Platform binary for @humanlayer/fold', + license: 'MIT', + repository, + preferUnplugged: true, + os: [os === 'windows' ? 'win32' : os], + cpu: [cpu], + files: ['bin'], + publishConfig: { access: 'public' }, + } + if (variant.includes('musl')) manifest.libc = ['musl'] await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) } From 59f02e77f978473c78170c310cd05300ae4d4d3d Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 19:35:27 -0700 Subject: [PATCH 5/5] refactor: use tagged model selection constructors HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a05a5f-b382-78e4-8082-ead4ae940a0b --- packages/fold-agent/src/Config/ModelSelections.ts | 3 ++- packages/fold-cli/src/tui/ModelSelectionState.ts | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index ade7213..d738d1c 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -2,7 +2,7 @@ import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_ANTHROPIC_MODEL_ID, type ModelCatalogEntry, type FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID, XAI_FRONTIER_MODELS } from '@humanlayer/fold-xai' -import { Effect, Match, Predicate } from 'effect' +import { Data, Effect, Match, Predicate } from 'effect' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' @@ -27,6 +27,7 @@ export type DirectModelSelection = { readonly reasoning?: RoleBinding['reasoning'] } export type ConfiguredModelSelection = ProfileModelSelection | DirectModelSelection +export const ConfiguredModelSelection = Data.taggedEnum() export type ModelConfiguration = { readonly profiles: ReadonlyArray<{ readonly name: string; readonly mode: ProfileConfig['mode'] | null }> diff --git a/packages/fold-cli/src/tui/ModelSelectionState.ts b/packages/fold-cli/src/tui/ModelSelectionState.ts index 6326e2a..f300df0 100644 --- a/packages/fold-cli/src/tui/ModelSelectionState.ts +++ b/packages/fold-cli/src/tui/ModelSelectionState.ts @@ -1,4 +1,4 @@ -import type { ConfiguredModelSelection, ModelConfiguration, ProfileModeName } from '@humanlayer/fold-agent' +import { ConfiguredModelSelection, type ModelConfiguration, type ProfileModeName } from '@humanlayer/fold-agent' import type { ReasoningLevel } from '@humanlayer/fold-core' type Mutable = { -readonly [Key in keyof Type]: Type[Key] } @@ -28,13 +28,12 @@ export type ModelPickerChoice = { readonly id: string; readonly label: string; r export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => { if (request._tag === 'profile') return request - const selection: Mutable> = { - _tag: 'direct', + const selection: Mutable, '_tag'>> = { provider: request.provider, model: request.model, } if (request.reasoning !== undefined) selection.reasoning = request.reasoning - return selection + return ConfiguredModelSelection.direct(selection) } const REASONING_LEVELS: ReadonlyArray<{ id: ReasoningLevel; label: string; detail: string }> = [