diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 4a925594..5b3acf23 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -1,5 +1,6 @@ import axios from 'axios' import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types' +import { applyHubSessionSummaryContract } from '@/modules/common/sessionSummaryInstruction' import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol' import { AgentStateSchema, @@ -84,6 +85,10 @@ export class ApiClient { throw apiValidationError('Invalid /cli/sessions response', response) } + if (typeof parsed.data.sessionSummaryContract === 'boolean') { + applyHubSessionSummaryContract(parsed.data.sessionSummaryContract) + } + const raw = parsed.data.session const metadata = (() => { @@ -136,6 +141,10 @@ export class ApiClient { throw apiValidationError('Invalid /cli/sessions/:id response', response) } + if (typeof parsed.data.sessionSummaryContract === 'boolean') { + applyHubSessionSummaryContract(parsed.data.sessionSummaryContract) + } + const raw = parsed.data.session const metadata = (() => { if (raw.metadata == null) return null diff --git a/cli/src/claude/claudeLocal.test.ts b/cli/src/claude/claudeLocal.test.ts index 7ecc67fd..1ebca1d5 100644 --- a/cli/src/claude/claudeLocal.test.ts +++ b/cli/src/claude/claudeLocal.test.ts @@ -25,7 +25,7 @@ vi.mock('./utils/mcpConfig', () => ({ })) vi.mock('./utils/systemPrompt', () => ({ - systemPrompt: 'HAPI system prompt' + getSystemPrompt: () => 'HAPI system prompt' })) vi.mock('@/utils/bunRuntime', () => ({ diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 5bf1082d..43896bdb 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -3,7 +3,7 @@ import { logger } from "@/ui/logger"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; import { appendMcpConfigArg } from "./utils/mcpConfig"; -import { systemPrompt } from "./utils/systemPrompt"; +import { getSystemPrompt } from "./utils/systemPrompt"; import { withBunRuntimeEnv } from "@/utils/bunRuntime"; import { spawnWithTerminalGuard } from "@/utils/spawnWithTerminalGuard"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; @@ -73,7 +73,7 @@ export async function claudeLocal(opts: { args.push('--resume', startFrom); } - args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(systemPrompt)); + args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(getSystemPrompt())); const cleanupMcpConfig = appendMcpConfigArg(args, opts.mcpServers, { baseDir: projectDir diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 8041462d..5133af26 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -7,7 +7,7 @@ import { logger } from "@/lib"; import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; import { getProjectPath } from "./utils/path"; import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; -import { systemPrompt } from "./utils/systemPrompt"; +import { getSystemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; @@ -157,6 +157,7 @@ export async function claudeRemote(opts: { // Prepare SDK options. For --fork-session, start query() before waiting for the // first child prompt so the native fork materializes at the clicked source state. + const hapiSystemPrompt = getSystemPrompt(); const sdkOptions: Options = { additionalArgs: filterCatalogAffectingClaudeArgs(opts.claudeArgs), cwd: opts.path, @@ -168,11 +169,11 @@ export async function claudeRemote(opts: { effort: bootstrapMode.effort, fallbackModel: bootstrapMode.fallbackModel, customSystemPrompt: bootstrapMode.customSystemPrompt - ? bootstrapMode.customSystemPrompt + '\n\n' + systemPrompt + ? bootstrapMode.customSystemPrompt + '\n\n' + hapiSystemPrompt : undefined, appendSystemPrompt: bootstrapMode.appendSystemPrompt - ? bootstrapMode.appendSystemPrompt + '\n\n' + systemPrompt - : systemPrompt, + ? bootstrapMode.appendSystemPrompt + '\n\n' + hapiSystemPrompt + : hapiSystemPrompt, allowedTools: bootstrapMode.allowedTools ? bootstrapMode.allowedTools.concat(opts.allowedTools) : opts.allowedTools, @@ -195,11 +196,11 @@ export async function claudeRemote(opts: { sdkOptions.effort = first.mode.effort; sdkOptions.fallbackModel = first.mode.fallbackModel; sdkOptions.customSystemPrompt = first.mode.customSystemPrompt - ? first.mode.customSystemPrompt + '\n\n' + systemPrompt + ? first.mode.customSystemPrompt + '\n\n' + hapiSystemPrompt : undefined; sdkOptions.appendSystemPrompt = first.mode.appendSystemPrompt - ? first.mode.appendSystemPrompt + '\n\n' + systemPrompt - : systemPrompt; + ? first.mode.appendSystemPrompt + '\n\n' + hapiSystemPrompt + : hapiSystemPrompt; sdkOptions.allowedTools = first.mode.allowedTools ? first.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools; diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index c7c0a904..7462008a 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,6 +1,7 @@ import { trimIdent } from "@/utils/trimIdent"; import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; +import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** * Base system prompt shared across all configurations @@ -29,15 +30,14 @@ const CO_AUTHORED_CREDITS = (() => trimIdent(` `))(); /** - * System prompt with conditional Co-Authored-By lines based on Claude's settings.json configuration. - * Settings are read once on startup for performance. + * Resolve the Claude append-system-prompt text. + * Co-Authored-By is read once from Claude settings; session-summary contract + * is resolved at call time so hub toggle / env apply after session bootstrap. */ -export const systemPrompt = (() => { - const includeCoAuthored = shouldIncludeCoAuthoredBy(); - - if (includeCoAuthored) { - return BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS; - } else { - return BASE_SYSTEM_PROMPT; - } -})(); +export function getSystemPrompt(): string { + const includeCoAuthored = shouldIncludeCoAuthoredBy(); + const base = includeCoAuthored + ? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS + : BASE_SYSTEM_PROMPT; + return withSessionSummaryInstruction(base); +} diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index ea14ee55..91d8c4d6 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -6,7 +6,7 @@ import { buildCodexHookConfigArgs, buildModelReasoningEffortConfigArgs } from './utils/codexMcpConfig'; -import { codexSystemPrompt } from './utils/systemPrompt'; +import { getCodexSystemPrompt } from './utils/systemPrompt'; import type { ReasoningEffort } from './appServerTypes'; import { resolveCodexCommand } from './utils/codexExecutable'; import type { McpServersConfig } from './utils/buildHapiMcpBridge'; @@ -153,7 +153,7 @@ export async function codexLocal(opts: { } // Add developer instructions (system prompt) - args.push(...buildDeveloperInstructionsArg(codexSystemPrompt)); + args.push(...buildDeveloperInstructionsArg(getCodexSystemPrompt())); if (opts.codexArgs) { // Before the first launch, Codex still needs the user's selector (for diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 3d639354..0aca1cf4 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -1,7 +1,7 @@ import type { EnhancedMode } from '../loop'; import type { CodexCliOverrides } from './codexCliOverrides'; import type { McpServersConfig } from './buildHapiMcpBridge'; -import { codexSystemPrompt } from './systemPrompt'; +import { getCodexSystemPrompt } from './systemPrompt'; import type { ApprovalPolicy, SandboxMode, @@ -115,7 +115,7 @@ function resolveInstructions(args: { baseInstructions?: string; developerInstructions?: string; }): { baseInstructions: string; developerInstructions: string } { - const baseInstructions = args.baseInstructions ?? codexSystemPrompt; + const baseInstructions = args.baseInstructions ?? getCodexSystemPrompt(); const developerInstructions = args.developerInstructions ? `${baseInstructions}\n\n${args.developerInstructions}` : baseInstructions; diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd16ca09..f51ab80f 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -7,6 +7,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; +import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** * Title instruction for Codex to call the hapi MCP tool. @@ -29,5 +30,11 @@ export const TITLE_INSTRUCTION = trimIdent(` /** * The system prompt to inject via developer_instructions in local mode. + * Session-summary contract is resolved at call time (hub toggle / env). */ -export const codexSystemPrompt = TITLE_INSTRUCTION; +export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string { + return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) +} + +/** Alias kept for existing call sites / tests that expect a string constant name. */ +export const codexSystemPrompt = TITLE_INSTRUCTION diff --git a/cli/src/grok/grokRemoteLauncher.ts b/cli/src/grok/grokRemoteLauncher.ts index c0da98b9..e87dd548 100644 --- a/cli/src/grok/grokRemoteLauncher.ts +++ b/cli/src/grok/grokRemoteLauncher.ts @@ -19,7 +19,7 @@ import { } from './utils/grokBackend' import { GrokPermissionHandler } from './utils/permissionHandler' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' -import { GROK_TITLE_INSTRUCTION } from './utils/systemPrompt' +import { getGrokTitleInstruction } from './utils/systemPrompt' import { GrokConversationHistory } from './conversationHistory' import { isObject } from '@hapi/protocol' @@ -306,7 +306,7 @@ class GrokRemoteLauncher extends RemoteLauncherBase { ? `${PLAN_MODE_INSTRUCTION}\n\n${batch.message}` : batch.message if (!this.instructionsSent && !isSlashCommand) { - text = `${GROK_TITLE_INSTRUCTION}\n\n${text}` + text = `${getGrokTitleInstruction()}\n\n${text}` this.instructionsSent = true } const promptContent: PromptContent[] = [{ type: 'text', text }] diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts index 8579d8a4..695bc9ea 100644 --- a/cli/src/grok/utils/systemPrompt.ts +++ b/cli/src/grok/utils/systemPrompt.ts @@ -1,4 +1,9 @@ import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction' +import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction' export const GROK_TITLE_INSTRUCTION = `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}` + +export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { + return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env) +} diff --git a/cli/src/modules/common/sessionSummaryInstruction.test.ts b/cli/src/modules/common/sessionSummaryInstruction.test.ts new file mode 100644 index 00000000..731ebd5a --- /dev/null +++ b/cli/src/modules/common/sessionSummaryInstruction.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + applyHubSessionSummaryContract, + buildSessionSummaryInstruction, + isSessionSummaryContractEnabled, + resetSessionSummaryContractForTests, + SESSION_SUMMARY_CONTRACT_LINE, + sessionSummaryInstructionOrEmpty, + withSessionSummaryInstruction +} from './sessionSummaryInstruction' + +describe('sessionSummaryInstruction', () => { + afterEach(() => { + resetSessionSummaryContractForTests() + }) + + it('is disabled by default (upstream opt-in)', () => { + expect(isSessionSummaryContractEnabled({})).toBe(false) + expect(isSessionSummaryContractEnabled({ HAPI_SESSION_SUMMARY_CONTRACT: '' })).toBe(false) + expect(sessionSummaryInstructionOrEmpty({})).toBe('') + }) + + it('enables when hub preference is applied', () => { + applyHubSessionSummaryContract(true) + expect(isSessionSummaryContractEnabled({})).toBe(true) + expect(sessionSummaryInstructionOrEmpty({})).toContain(SESSION_SUMMARY_CONTRACT_LINE) + + applyHubSessionSummaryContract(false) + expect(isSessionSummaryContractEnabled({})).toBe(false) + }) + + it('lets explicit env override hub preference', () => { + applyHubSessionSummaryContract(true) + expect(isSessionSummaryContractEnabled({ HAPI_SESSION_SUMMARY_CONTRACT: '0' })).toBe(false) + applyHubSessionSummaryContract(false) + expect(isSessionSummaryContractEnabled({ HAPI_SESSION_SUMMARY_CONTRACT: '1' })).toBe(true) + expect(isSessionSummaryContractEnabled({ HAPI_SESSION_SUMMARY_CONTRACT: 'true' })).toBe(true) + }) + + it('treats common falsy env spellings as off', () => { + for (const value of ['0', 'false', 'off', 'no', 'FALSE', ' Off ']) { + expect(isSessionSummaryContractEnabled({ HAPI_SESSION_SUMMARY_CONTRACT: value })).toBe(false) + } + }) + + it('builds the canonical contract line without surveillance framing', () => { + const body = buildSessionSummaryInstruction() + expect(body).toContain(SESSION_SUMMARY_CONTRACT_LINE) + expect(body.toLowerCase()).toContain('session tracking') + expect(body.toLowerCase()).not.toContain('overseer') + expect(body.toLowerCase()).not.toContain('surveillance') + }) + + it('appends to an existing base prompt when enabled', () => { + applyHubSessionSummaryContract(true) + const out = withSessionSummaryInstruction('Be helpful.', {}) + expect(out.startsWith('Be helpful.')).toBe(true) + expect(out).toContain(SESSION_SUMMARY_CONTRACT_LINE) + }) + + it('leaves base unchanged when disabled', () => { + expect(withSessionSummaryInstruction('Be helpful.', {})).toBe('Be helpful.') + expect(withSessionSummaryInstruction('Be helpful.', { HAPI_SESSION_SUMMARY_CONTRACT: '0' })) + .toBe('Be helpful.') + }) +}) diff --git a/cli/src/modules/common/sessionSummaryInstruction.ts b/cli/src/modules/common/sessionSummaryInstruction.ts new file mode 100644 index 00000000..64459ddf --- /dev/null +++ b/cli/src/modules/common/sessionSummaryInstruction.ts @@ -0,0 +1,77 @@ +/** + * Optional session-status summary instruction for non-Cursor agent flavors. + * + * Cursor ACP has no system-prompt / rules-overlay seam in upstream today, so + * it is intentionally not covered here. Claude / Codex / OpenCode / Grok get + * this text via their existing system-prompt / developer-instructions / + * one-shot instruction paths when the operator opts in. + * + * Resolution order: + * 1. Explicit `HAPI_SESSION_SUMMARY_CONTRACT` env (0|false|off|no → off; anything else → on) + * 2. Hub preference applied via `applyHubSessionSummaryContract` (from session bootstrap) + * 3. Default: off (matches A2A RFC — emission stays optional indefinitely) + */ + +let hubPreference: boolean | undefined + +/** Apply the hub-resolved toggle from session create/get bootstrap. */ +export function applyHubSessionSummaryContract(enabled: boolean): void { + hubPreference = enabled +} + +/** Test-only: clear hub preference between cases. */ +export function resetSessionSummaryContractForTests(): void { + hubPreference = undefined +} + +export function isSessionSummaryContractEnabled( + env: NodeJS.ProcessEnv = process.env +): boolean { + const raw = env.HAPI_SESSION_SUMMARY_CONTRACT + if (raw !== undefined && raw !== '') { + const normalized = raw.trim().toLowerCase() + return !(normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') + } + return hubPreference === true +} + +/** + * Canonical trailing-line contract. Matches the FCM / native-companion parser + * in `@hapi/protocol` (`extractNotifySummary`). + */ +export const SESSION_SUMMARY_CONTRACT_LINE = + 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}' + +/** + * Body appended to flavor system / developer instructions when enabled. + * Keep short — rides every session's prompt budget. + */ +export function buildSessionSummaryInstruction(): string { + return [ + 'Session status summary:', + 'End every response with a single machine-readable status line (no backticks)', + 'so this workspace\'s session tracking can record progress. Put it on its own', + 'final line after all other content:', + SESSION_SUMMARY_CONTRACT_LINE, + 'Use status "blocked" if unsure. Keep action to 12 words or fewer when status', + 'is "done" and follow-up remains.' + ].join('\n') +} + +/** Empty string when disabled so callers can append unconditionally. */ +export function sessionSummaryInstructionOrEmpty( + env: NodeJS.ProcessEnv = process.env +): string { + return isSessionSummaryContractEnabled(env) ? buildSessionSummaryInstruction() : '' +} + +/** Append instruction to an existing prompt block (blank line separator). */ +export function withSessionSummaryInstruction( + base: string, + env: NodeJS.ProcessEnv = process.env +): string { + const extra = sessionSummaryInstructionOrEmpty(env) + if (!extra) return base + const trimmed = base.trimEnd() + return trimmed.length > 0 ? `${trimmed}\n\n${extra}` : extra +} diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index ad32e8b7..3699fb85 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -4,7 +4,7 @@ import { OpencodeSession } from './session'; import { ensureOpencodeHookPlugin } from './utils/hookPlugin'; import { buildOpencodeEnv } from './utils/config'; import { ensureOpencodeConfig } from './utils/opencodeConfig'; -import { TITLE_INSTRUCTION } from './utils/systemPrompt'; +import { getTitleInstruction } from './utils/systemPrompt'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import type { OpencodeHookEvent } from './types'; import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; @@ -194,7 +194,7 @@ export async function opencodeLocalLauncher( logger.debug(`[opencode-local]: Started hapi MCP server at ${happyServer.url}`); // Generate opencode.json config with MCP server and instructions - const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, TITLE_INSTRUCTION); + const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, getTitleInstruction()); opencodeConfigPath = configPath; } catch (error) { logger.debug('[opencode-local]: Failed to start hapi MCP server (change_title will be unavailable)', error); diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index cdadbd94..12e91c4d 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -15,7 +15,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { allocateFreePort, createOpencodeBackend } from './utils/opencodeBackend'; import { captureCompactionMarkerSnapshot, fetchCompactionResult, splitProviderModel, triggerOpencodeCompact } from './utils/opencodeCompactBridge'; import { OpencodePermissionHandler } from './utils/permissionHandler'; -import { OPENCODE_NATIVE_TOOL_INSTRUCTION, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; +import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; type OpencodeRemoteLauncherOptions = { @@ -565,7 +565,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`; } if (!this.instructionsSent) { - messageText = `${OPENCODE_NATIVE_TOOL_INSTRUCTION}\n\n${messageText}`; + messageText = `${getOpencodeNativeToolInstruction()}\n\n${messageText}`; this.instructionsSent = true; } diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index cac44660..f57a1230 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -8,6 +8,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction'; +import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** * Title instruction for OpenCode to call the hapi MCP tool. @@ -23,6 +24,10 @@ export const TITLE_INSTRUCTION = trimIdent(` ${SKILL_LOOKUP_INSTRUCTION} `); +export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { + return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) +} + /** * Tool instructions for native ACP sessions. Title updates come from ACP, so * advertise only the MCP tools that remain available to the model. @@ -37,6 +42,10 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` ${SKILL_LOOKUP_INSTRUCTION} `); +export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string { + return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env) +} + /** * The system prompt to inject for OpenCode sessions. */ diff --git a/hub/src/config/sessionSummaryContract.test.ts b/hub/src/config/sessionSummaryContract.test.ts new file mode 100644 index 00000000..04932761 --- /dev/null +++ b/hub/src/config/sessionSummaryContract.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + readSessionSummaryContractEnabled, + writeSessionSummaryContractEnabled +} from './sessionSummaryContract' +import { getSettingsFile, updateSettings, writeSettings } from './settings' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('sessionSummaryContract setting', () => { + it('defaults to off when settings.json is missing or unset', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-ssc-')) + directories.push(dataDir) + expect(await readSessionSummaryContractEnabled(dataDir)).toBe(false) + + await writeSettings(getSettingsFile(dataDir), { listenPort: 3006 }) + expect(await readSessionSummaryContractEnabled(dataDir)).toBe(false) + }) + + it('persists true across reads', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-ssc-')) + directories.push(dataDir) + expect(await writeSessionSummaryContractEnabled(dataDir, true)).toBe(true) + expect(await readSessionSummaryContractEnabled(dataDir)).toBe(true) + + const raw = JSON.parse(await readFile(getSettingsFile(dataDir), 'utf8')) as { + sessionSummaryContract?: boolean + } + expect(raw.sessionSummaryContract).toBe(true) + + await writeSessionSummaryContractEnabled(dataDir, false) + expect(await readSessionSummaryContractEnabled(dataDir)).toBe(false) + }) + + it('serializes concurrent writers without clobbering unrelated fields', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-ssc-race-')) + directories.push(dataDir) + const settingsFile = getSettingsFile(dataDir) + await writeSettings(settingsFile, { + cliApiToken: 'keep-me', + machineId: 'machine-1', + relayAuthKey: 'relay-keep', + listenPort: 3006 + }) + + const results = await Promise.all([ + writeSessionSummaryContractEnabled(dataDir, true), + updateSettings(settingsFile, (current) => ({ + ...current, + publicUrl: 'https://example.test' + })), + writeSessionSummaryContractEnabled(dataDir, true) + ]) + + expect(results[0]).toBe(true) + expect(results[2]).toBe(true) + + const raw = JSON.parse(await readFile(settingsFile, 'utf8')) as { + cliApiToken?: string + machineId?: string + relayAuthKey?: string + listenPort?: number + publicUrl?: string + sessionSummaryContract?: boolean + } + expect(raw.cliApiToken).toBe('keep-me') + expect(raw.machineId).toBe('machine-1') + expect(raw.relayAuthKey).toBe('relay-keep') + expect(raw.listenPort).toBe(3006) + expect(raw.publicUrl).toBe('https://example.test') + expect(raw.sessionSummaryContract).toBe(true) + expect(await readSessionSummaryContractEnabled(dataDir)).toBe(true) + }) +}) diff --git a/hub/src/config/sessionSummaryContract.ts b/hub/src/config/sessionSummaryContract.ts new file mode 100644 index 00000000..d2e68d95 --- /dev/null +++ b/hub/src/config/sessionSummaryContract.ts @@ -0,0 +1,31 @@ +import { + getSettingsFile, + readSettingsOrThrow, + updateSettings, + type Settings +} from './settings' + +/** + * Hub-persisted opt-in for AGENT_NOTIFY_SUMMARY prompt injection. + * Default is off (undefined / false). Env `HAPI_SESSION_SUMMARY_CONTRACT` on + * the CLI process remains an escape hatch and is resolved client-side. + */ +export function isSessionSummaryContractSettingEnabled(settings: Settings): boolean { + return settings.sessionSummaryContract === true +} + +export async function readSessionSummaryContractEnabled(dataDir: string): Promise { + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return isSessionSummaryContractSettingEnabled(settings) +} + +export async function writeSessionSummaryContractEnabled( + dataDir: string, + enabled: boolean +): Promise { + const next = await updateSettings(getSettingsFile(dataDir), (current) => ({ + ...current, + sessionSummaryContract: enabled + })) + return next.sessionSummaryContract === true +} diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 0c2be780..6fc29aed 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' import { dirname, join } from 'node:path' export interface Settings { @@ -22,6 +23,11 @@ export interface Settings { corsOrigins?: string[] /** Per-hub relay auth key issued by the relay server (/issue) */ relayAuthKey?: string + /** + * When true, CLI injects the AGENT_NOTIFY_SUMMARY trailing-line contract + * into supported flavor system / developer instructions. Default off. + */ + sessionSummaryContract?: boolean } export function getSettingsFile(dataDir: string): string { @@ -56,16 +62,92 @@ export async function readSettingsOrThrow(settingsFile: string): Promise { +async function writeSettingsAtomic(settingsFile: string, settings: Settings): Promise { const dir = dirname(settingsFile) if (!existsSync(dir)) { await mkdir(dir, { recursive: true, mode: 0o700 }) } - const tmpFile = settingsFile + '.tmp' - await writeFile(tmpFile, JSON.stringify(settings, null, 2)) - await rename(tmpFile, settingsFile) + // Unique temp path so concurrent writers cannot clobber each other's + // staging file before rename (Codex #1376 Major). + const tmpFile = join(dir, `.settings.${randomUUID()}.tmp`) + try { + await writeFile(tmpFile, JSON.stringify(settings, null, 2)) + await rename(tmpFile, settingsFile) + } catch (error) { + await unlink(tmpFile).catch(() => {}) + throw error + } +} + +/** + * Write settings to file atomically (unique temp file + rename). + * Prefer {@link updateSettings} for read-modify-write so concurrent writers + * serialize via the shared lock file. + */ +export async function writeSettings(settingsFile: string, settings: Settings): Promise { + await writeSettingsAtomic(settingsFile, settings) +} + +/** + * Atomically update settings with multi-process safety via file locking. + * Lock path matches CLI (`${settingsFile}.lock`) so hub and CLI serialize + * when they share the same ~/.hapi/settings.json. + */ +export async function updateSettings( + settingsFile: string, + updater: (current: Settings) => Settings | Promise +): Promise { + const LOCK_RETRY_INTERVAL_MS = 100 + const MAX_LOCK_ATTEMPTS = 50 + const STALE_LOCK_TIMEOUT_MS = 10_000 + + const dir = dirname(settingsFile) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true, mode: 0o700 }) + } + + const lockFile = `${settingsFile}.lock` + let fileHandle: Awaited> | undefined + let attempts = 0 + + while (attempts < MAX_LOCK_ATTEMPTS) { + try { + // 'wx' = create exclusively, fail if exists (cross-platform) + fileHandle = await open(lockFile, 'wx') + break + } catch (err: unknown) { + const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined + if (code === 'EEXIST') { + attempts++ + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)) + try { + const stats = await stat(lockFile) + if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) { + await unlink(lockFile).catch(() => {}) + } + } catch { + // ignore stale-check races + } + } else { + throw err + } + } + } + + if (!fileHandle) { + throw new Error( + `Failed to acquire settings lock after ${(MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS) / 1000} seconds` + ) + } + + try { + const current = await readSettingsOrThrow(settingsFile) + const updated = await updater(current) + await writeSettingsAtomic(settingsFile, updated) + return updated + } finally { + await fileHandle.close() + await unlink(lockFile).catch(() => {}) + } } diff --git a/hub/src/tunnel/relayAuth.ts b/hub/src/tunnel/relayAuth.ts index 39e04ab8..069f314b 100644 --- a/hub/src/tunnel/relayAuth.ts +++ b/hub/src/tunnel/relayAuth.ts @@ -9,7 +9,7 @@ * tunnel — there is no shared-key fallback. */ -import { readSettings, writeSettings, type Settings } from '../config/settings' +import { readSettings, updateSettings, type Settings } from '../config/settings' type FetchRelayAuth = (input: string | URL | Request, init?: RequestInit) => Promise @@ -43,7 +43,10 @@ async function issueRelayAuthKey( } // settings === null means the file exists but is unparseable; don't clobber it if (settings !== null) { - await writeSettings(settingsFile, { ...settings, relayAuthKey: data.key }) + await updateSettings(settingsFile, (current) => ({ + ...current, + relayAuthKey: data.key + })) } console.log('[Tunnel] Obtained per-hub relay auth key') return data.key @@ -88,9 +91,11 @@ export async function refreshRejectedRelayAuthKey( return settings.relayAuthKey } - const clearedSettings = { ...settings } - delete clearedSettings.relayAuthKey - await writeSettings(settingsFile, clearedSettings) + const cleared = await updateSettings(settingsFile, (current) => { + const next = { ...current } + delete next.relayAuthKey + return next + }) console.warn('[Tunnel] Relay auth key rejected; requesting a replacement') - return issueRelayAuthKey(apiDomain, settingsFile, clearedSettings, fetchRelayAuth) + return issueRelayAuthKey(apiDomain, settingsFile, cleared, fetchRelayAuth) } diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index c31696b9..5e234586 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -8,6 +8,7 @@ import { PROTOCOL_VERSION } from '@hapi/protocol' import { getConfiguration } from '../../configuration' +import { readSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' import { constantTimeEquals } from '../../utils/crypto' import { parseAccessToken } from '../../utils/accessToken' import type { Machine, Session, SyncEngine } from '../../sync/syncEngine' @@ -128,7 +129,10 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono SyncEngine | null): Hono { + app.get('/sessions/:id', async (c) => { const engine = getSyncEngine() if (!engine) { return c.json({ error: 'Not ready' }, 503) @@ -245,7 +249,10 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts new file mode 100644 index 00000000..9b3f5490 --- /dev/null +++ b/hub/src/web/routes/hubSettings.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import type { WebAppEnv } from '../middleware/auth' +import { createHubSettingsRoutes } from './hubSettings' +import { writeSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('GET/PUT /api/hub-settings', () => { + async function createApp(namespace = 'default') { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-hub-settings-')) + directories.push(dataDir) + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createHubSettingsRoutes(dataDir)) + return { app, dataDir } + } + + it('returns default off', async () => { + const { app } = await createApp() + const response = await app.request('/api/hub-settings') + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ sessionSummaryContract: false }) + }) + + it('persists toggle for owner', async () => { + const { app } = await createApp() + const put = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionSummaryContract: true }) + }) + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ sessionSummaryContract: true }) + + const get = await app.request('/api/hub-settings') + expect(await get.json()).toEqual({ sessionSummaryContract: true }) + }) + + it('rejects invalid body', async () => { + const { app } = await createApp() + const response = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionSummaryContract: 'yes' }) + }) + expect(response.status).toBe(400) + }) + + it('rejects non-default namespaces', async () => { + const { app } = await createApp('tenant') + const get = await app.request('/api/hub-settings') + expect(get.status).toBe(403) + + const put = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionSummaryContract: true }) + }) + expect(put.status).toBe(403) + }) + + it('survives a prior write via settings helper', async () => { + const { app, dataDir } = await createApp() + await writeSessionSummaryContractEnabled(dataDir, true) + const response = await app.request('/api/hub-settings') + expect(await response.json()).toEqual({ sessionSummaryContract: true }) + }) +}) diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts new file mode 100644 index 00000000..f641aa7a --- /dev/null +++ b/hub/src/web/routes/hubSettings.ts @@ -0,0 +1,43 @@ +import { Hono } from 'hono' +import { UpdateHubSettingsRequestSchema, type HubSettingsResponse } from '@hapi/protocol' +import { + readSessionSummaryContractEnabled, + writeSessionSummaryContractEnabled +} from '../../config/sessionSummaryContract' +import type { WebAppEnv } from '../middleware/auth' + +const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' + +export function createHubSettingsRoutes(dataDir: string): Hono { + const app = new Hono() + + app.get('/hub-settings', async (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ error: OWNER_ONLY_ERROR }, 403) + } + c.header('Cache-Control', 'no-store') + const enabled = await readSessionSummaryContractEnabled(dataDir) + const response: HubSettingsResponse = { sessionSummaryContract: enabled } + return c.json(response) + }) + + app.put('/hub-settings', async (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ error: OWNER_ONLY_ERROR }, 403) + } + const json = await c.req.json().catch(() => null) + const parsed = UpdateHubSettingsRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + const enabled = await writeSessionSummaryContractEnabled( + dataDir, + parsed.data.sessionSummaryContract + ) + c.header('Cache-Control', 'no-store') + const response: HubSettingsResponse = { sessionSummaryContract: enabled } + return c.json(response) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index e838ec17..a94ac4ef 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -28,6 +28,7 @@ import { createPiSessionRoutes } from './routes/piSessions' import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' +import { createHubSettingsRoutes } from './routes/hubSettings' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -237,7 +238,7 @@ function createWebApp(options: { const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins const corsMiddleware = cors({ origin: corsOriginOption, - allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], // last-event-id: browsers attach it to EventSource reconnects for // SSE replay; allow it in case a browser preflights the request. allowHeaders: ['authorization', 'content-type', 'last-event-id'] @@ -278,6 +279,7 @@ function createWebApp(options: { app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createStorageRoutes(configuration.dbPath)) + app.route('/api', createHubSettingsRoutes(configuration.dataDir)) app.route('/api', createUsageRoutes(options.store)) app.route('/api', createGitRoutes(options.getSyncEngine)) // 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。 diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f53a86d7..ca7f333d 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -50,11 +50,25 @@ export const CliMessagesResponseSchema = z.object({ export type CliMessagesResponse = z.infer export const CreateSessionResponseSchema = z.object({ - session: SessionSchema + session: SessionSchema, + /** Hub opt-in for AGENT_NOTIFY_SUMMARY prompt injection (default off when omitted). */ + sessionSummaryContract: z.boolean().optional() }) export type CreateSessionResponse = z.infer +export const HubSettingsResponseSchema = z.object({ + sessionSummaryContract: z.boolean() +}) + +export type HubSettingsResponse = z.infer + +export const UpdateHubSettingsRequestSchema = z.object({ + sessionSummaryContract: z.boolean() +}) + +export type UpdateHubSettingsRequest = z.infer + export const CreateMachineResponseSchema = z.object({ machine: MachineSchema }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 23a59874..469b29a7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,7 @@ import type { QueuedStateResponse, ReopenSessionResponse, SqliteStorageUsageResponse, + HubSettingsResponse, UsageSummaryResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' @@ -676,6 +677,17 @@ export class ApiClient { return await this.request('/api/storage/sqlite') } + async getHubSettings(): Promise { + return await this.request('/api/hub-settings') + } + + async updateHubSettings(settings: HubSettingsResponse): Promise { + return await this.request('/api/hub-settings', { + method: 'PUT', + body: JSON.stringify(settings) + }) + } + async getUsageSummary( range: '7d' | '30d' | 'all' = '7d', timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 488c9d05..5a51fc4a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -715,6 +715,10 @@ export default { 'settings.usage.bucketDetails': '{requests} requests · {input} in · {output} out', 'settings.usage.sessions': '{count} sessions with usage', 'settings.general.description': 'Language, companion pairing, and general application preferences.', + 'settings.general.agents.title': 'Agents', + 'settings.general.agents.description': 'Hub-wide defaults for how agents behave in new and resumed sessions.', + 'settings.general.sessionSummaryContract': 'Ask agents to emit session status summary', + 'settings.general.sessionSummaryContract.desc': 'When on, Claude, Codex, OpenCode, and remote Grok sessions are asked to end each turn with an AGENT_NOTIFY_SUMMARY line for denser ready notifications. Off by default. Local Grok and Cursor are not covered yet. Applies to new/resumed sessions.', 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index f98442b5..0fa77624 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -714,6 +714,10 @@ export default { 'settings.usage.bucketDetails': '{requests} 次请求 · 输入 {input} · 输出 {output}', 'settings.usage.sessions': '{count} 个会话有用量记录', 'settings.general.description': '语言、伴侣应用配对和通用应用偏好。', + 'settings.general.agents.title': '智能体', + 'settings.general.agents.description': '适用于新建与恢复会话的中心级智能体默认行为。', + 'settings.general.sessionSummaryContract': '要求智能体输出会话状态摘要', + 'settings.general.sessionSummaryContract.desc': '开启后,Claude、Codex、OpenCode 以及远程 Grok 会话会在每轮结束时追加 AGENT_NOTIFY_SUMMARY 行,便于更清晰的就绪通知。默认关闭。本地 Grok 与 Cursor 暂不覆盖。对新开/恢复的会话生效。', 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index aceca5df..cc00e0f6 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,6 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, + hubSettings: ['hub-settings'] as const, usageSummary: (range: string, timeZone: string) => ['usage-summary', range, timeZone] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index 8210cd51..e4180d8b 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -1,21 +1,75 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation, type Locale } from '@/lib/use-translation' import { useAppContext } from '@/lib/app-context' import { CompanionPairing } from '@/components/settings/CompanionPairing' -import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { SettingsChoiceGroup, SettingsPageContent, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' +import { queryKeys } from '@/lib/query-keys' const locales: ReadonlyArray<{ value: Locale; label: string }> = [ { value: 'en', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, ] +function getNamespace(token: string | null): string | null { + if (!token) return null + try { + const payload = token.split('.')[1] + if (!payload) return null + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=') + const decoded = JSON.parse(atob(base64)) as { ns?: unknown } + return typeof decoded.ns === 'string' ? decoded.ns : null + } catch { + return null + } +} + export default function SettingsGeneralPage() { const { t, locale, setLocale } = useTranslation() - const { baseUrl } = useAppContext() + const { api, baseUrl, token } = useAppContext() + const queryClient = useQueryClient() + const isOwner = getNamespace(token) === 'default' + + const hubSettingsQuery = useQuery({ + queryKey: queryKeys.hubSettings, + queryFn: async () => { + if (!api) throw new Error('API unavailable') + return await api.getHubSettings() + }, + enabled: Boolean(api) && isOwner, + staleTime: 30_000, + retry: false, + }) + + const hubSettingsMutation = useMutation({ + mutationFn: async (sessionSummaryContract: boolean) => { + if (!api) throw new Error('API unavailable') + return await api.updateHubSettings({ sessionSummaryContract }) + }, + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.hubSettings, data) + }, + }) + return ( + {isOwner ? ( + + {hubSettingsQuery.data ? ( + { + if (hubSettingsMutation.isPending) return + hubSettingsMutation.mutate(checked) + }} + /> + ) : null} + + ) : null}
diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 359d8dbe..114099b5 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { I18nProvider } from '@/lib/i18n-context' import SettingsHubPage from './index' import SettingsGeneralPage from './general' @@ -22,6 +23,9 @@ const { context, navigate, setAppearance, setColorTheme, setFontScale, setTermin setVoice: vi.fn(), })) +const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false }) +const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true }) + vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => ({ colorTheme: 'default', setColorTheme }), getColorThemeOptions: () => [ @@ -156,7 +160,7 @@ vi.mock('@/hooks/useChatSurfaceColors', () => ({ vi.mock('@/lib/app-context', () => ({ useAppContext: () => ({ - api: {}, + api: { getHubSettings, updateHubSettings }, baseUrl: 'http://127.0.0.1:3006', token: context.token, }), @@ -200,13 +204,20 @@ vi.mock('./useVoiceSettings', () => ({ })) function renderPage(page: React.ReactElement) { - return render({page}) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + {page} + , + ) } describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() + getHubSettings.mockResolvedValue({ sessionSummaryContract: false }) + updateHubSettings.mockResolvedValue({ sessionSummaryContract: true }) context.token = `x.${btoa(JSON.stringify({ ns: 'default' }))}.x` }) @@ -230,10 +241,11 @@ describe('responsive settings pages', () => { expect(screen.queryByText('Hub database usage')).not.toBeInTheDocument() }) - it('changes the application language inline', () => { + it('changes the application language inline', async () => { renderPage() expect(screen.getByText('Companion')).toBeInTheDocument() expect(screen.getByText('Companion pairing')).toBeInTheDocument() + expect(await screen.findByRole('checkbox', { name: 'Ask agents to emit session status summary' })).toBeInTheDocument() fireEvent.click(screen.getByRole('radio', { name: '简体中文' })) expect(localStorage.getItem('hapi-lang')).toBe('zh-CN') })