diff --git a/src/oclif/commands/curate/index.ts b/src/oclif/commands/curate/index.ts index 11995ad11..95a30d71c 100644 --- a/src/oclif/commands/curate/index.ts +++ b/src/oclif/commands/curate/index.ts @@ -7,6 +7,7 @@ import type {CurateLogOperation} from '../../../server/core/domain/entities/cura import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../server/constants.js' import {ProviderConfigResponse, TransportStateEventNames} from '../../../server/core/domain/transport/index.js' +import {formatBlockedCurationMessage, isBlockedCurationResponse} from '../../../server/utils/curate-outcome.js' import {extractCurateOperations} from '../../../server/utils/curate-result-parser.js' import {TaskEvents} from '../../../shared/transport/events/index.js' import {printBillingLine} from '../../lib/billing-line.js' @@ -154,7 +155,16 @@ Bad examples: await ensureBillingFunds({billing, client}) } - await this.submitTask({client, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot}) + await this.submitTask({ + client, + content: resolvedContent, + flags, + format, + projectRoot, + providerContext, + taskType, + worktreeRoot, + }) }, { ...this.getDaemonClientOptions(), @@ -199,20 +209,22 @@ Bad examples: * Best-effort enrichment: returns per-file detail when tool results include needsReview. * The authoritative signal for whether review is required comes from ReviewEvents.NOTIFY. */ - private collectPendingReviewOps(toolCalls: ToolCallRecord[]): CurateLogOperation[] { + private collectCurateOperations(toolCalls: ToolCallRecord[]): CurateLogOperation[] { const pending: CurateLogOperation[] = [] for (const tc of toolCalls) { if (tc.status !== 'completed') continue const ops = extractCurateOperations({result: tc.result, toolName: tc.toolName}) - for (const op of ops) { - if (op.needsReview === true) pending.push(op) - } + pending.push(...ops) } return pending } + private collectPendingReviewOps(toolCalls: ToolCallRecord[]): CurateLogOperation[] { + return this.collectCurateOperations(toolCalls).filter((op) => op.needsReview === true) + } + /** * Extract file changes from collected tool calls (same logic as TUI useActivityLogs). */ @@ -287,12 +299,12 @@ Bad examples: } private reportError(error: unknown, format: 'json' | 'text', providerContext?: ProviderErrorContext): void { - const errorMessage = error instanceof Error ? error.message : 'Curate failed' + const errorMessage = formatConnectionError(error, providerContext) if (format === 'json') { writeJsonResponse({command: 'curate', data: {error: errorMessage, status: 'error'}, success: false}) } else { - this.log(formatConnectionError(error, providerContext)) + this.log(errorMessage) } if (hasLeakedHandles(error)) { @@ -307,10 +319,11 @@ Bad examples: flags: CurateFlags format: 'json' | 'text' projectRoot?: string + providerContext?: ProviderErrorContext taskType: string worktreeRoot?: string }): Promise { - const {client, content, flags, format, projectRoot, taskType, worktreeRoot} = props + const {client, content, flags, format, projectRoot, providerContext, taskType, worktreeRoot} = props const hasFolders = Boolean(flags.folder?.length) const taskId = randomUUID() const taskPayload = { @@ -344,10 +357,20 @@ Bad examples: client, command: 'curate', format, - onCompleted: ({logId, pendingReview, taskId: tid, toolCalls}) => { + onCompleted: ({logId, pendingReview, result, taskId: tid, toolCalls}) => { const changes = this.composeChangesFromToolCalls(toolCalls) + const operations = this.collectCurateOperations(toolCalls) + const hasAppliedOperations = operations.some((op) => op.status === 'success') + const hasFailedOperations = operations.some((op) => op.status === 'failed') + const blocked = !hasAppliedOperations && isBlockedCurationResponse(result) // Per-file detail is best-effort enrichment; server notify is authoritative const pendingOps = pendingReview ? this.collectPendingReviewOps(toolCalls) : [] + const suffix = logId ? ` (Task: ${tid} · Log: ${logId})` : ` (Task: ${tid})` + const message = blocked + ? formatBlockedCurationMessage(result) + : !hasAppliedOperations && !hasFailedOperations && !pendingReview + ? 'No context changes applied' + : 'Context curated successfully' if (format === 'text') { for (const file of changes.created) { @@ -358,8 +381,9 @@ Bad examples: this.log(` update ${file}`) } - const suffix = logId ? ` (Task: ${tid} · Log: ${logId})` : ` (Task: ${tid})` - this.log(`✓ Context curated successfully.${suffix}`) + const icon = + blocked ? '✗' : hasAppliedOperations || hasFailedOperations || pendingReview ? '✓' : 'ℹ' + this.log(`${icon} ${message}.${suffix}`) if (pendingReview) { this.printPendingReviewSummary(pendingReview.pendingCount, pendingOps, tid) @@ -371,22 +395,26 @@ Bad examples: changes: changes.created.length > 0 || changes.updated.length > 0 ? changes : undefined, event: 'completed', logId, - message: 'Context curated successfully', + message, ...(pendingReview ? {pendingReview: this.buildPendingReviewJson(pendingReview.pendingCount, pendingOps, tid)} : {}), - status: 'completed', + status: blocked ? 'error' : 'completed', taskId: tid, }, - success: true, + success: !blocked, }) } }, onError({error, logId}) { if (format === 'json') { + const message = formatConnectionError( + Object.assign(new Error(error.message), error.code ? {code: error.code} : {}), + providerContext, + ) writeJsonResponse({ command: 'curate', - data: {event: 'error', logId, message: error.message, status: 'error'}, + data: {event: 'error', logId, message, status: 'error'}, success: false, }) } diff --git a/src/oclif/commands/query.ts b/src/oclif/commands/query.ts index d8ebcf0d4..795804fbc 100644 --- a/src/oclif/commands/query.ts +++ b/src/oclif/commands/query.ts @@ -110,6 +110,7 @@ Bad: client, format, projectRoot, + providerContext, query: args.query, worktreeRoot, }) @@ -129,12 +130,12 @@ Bad: } private reportError(error: unknown, format: 'json' | 'text', providerContext?: ProviderErrorContext): void { - const errorMessage = error instanceof Error ? error.message : 'Query failed' + const errorMessage = formatConnectionError(error, providerContext) if (format === 'json') { writeJsonResponse({command: 'query', data: {error: errorMessage, status: 'error'}, success: false}) } else { - this.log(formatConnectionError(error, providerContext)) + this.log(errorMessage) } if (hasLeakedHandles(error)) { @@ -147,10 +148,11 @@ Bad: client: ITransportClient format: 'json' | 'text' projectRoot?: string + providerContext?: ProviderErrorContext query: string worktreeRoot?: string }): Promise { - const {client, format, projectRoot, query, worktreeRoot} = props + const {client, format, projectRoot, providerContext, query, worktreeRoot} = props const taskId = randomUUID() const taskPayload = { clientCwd: process.cwd(), @@ -213,9 +215,13 @@ Bad: }, onError({error}) { if (format === 'json') { + const message = formatConnectionError( + Object.assign(new Error(error.message), error.code ? {code: error.code} : {}), + providerContext, + ) writeJsonResponse({ command: 'query', - data: {event: 'error', message: error.message, status: 'error'}, + data: {event: 'error', message, status: 'error'}, success: false, }) } diff --git a/src/oclif/lib/daemon-client.ts b/src/oclif/lib/daemon-client.ts index 363db07a7..c58970a78 100644 --- a/src/oclif/lib/daemon-client.ts +++ b/src/oclif/lib/daemon-client.ts @@ -31,7 +31,7 @@ const USER_FRIENDLY_MESSAGES: Record = { [TaskErrorCode.CONTEXT_TREE_NOT_INITIALIZED]: 'Context tree not initialized.', [TaskErrorCode.LOCAL_CHANGES_EXIST]: 'You have local changes. Run "brv push" to save your changes before pulling.', [TaskErrorCode.NOT_AUTHENTICATED]: - 'Not authenticated. Cloud sync features (push/pull/space) require login — local query and curate work without authentication.', + 'Not authenticated. Cloud sync features (push/pull/space) require login. Run "brv login" to connect your account.', [TaskErrorCode.OAUTH_REFRESH_FAILED]: 'OAuth token refresh failed. Run "brv providers connect --oauth" to reconnect.', [TaskErrorCode.OAUTH_TOKEN_EXPIRED]: @@ -209,32 +209,35 @@ export function formatConnectionError(error: unknown, providerContext?: Provider } if (error instanceof ConnectionFailedError) { - const isSandboxError = isSandboxNetworkError(error.originalError ?? error) + const connectionFailedError = error as {message?: string; originalError?: unknown; port?: number} + const isSandboxError = isSandboxNetworkError((connectionFailedError.originalError ?? error) as Error | string) if (isSandboxError) { const sandboxName = getSandboxEnvironmentName() return ( `Failed to connect to the daemon.\n` + - `Port: ${error.port ?? 'unknown'}\n` + + `Port: ${connectionFailedError.port ?? 'unknown'}\n` + `⚠️ Sandbox network restriction detected (${sandboxName}).\n\n` + `Please allow network access in the sandbox and retry the command.` ) } - return `Failed to connect to the daemon: ${error.message}\nRun 'brv restart' if the daemon is unresponsive.` + return `Failed to connect to the daemon: ${connectionFailedError.message ?? 'unknown error'}\nRun 'brv restart' if the daemon is unresponsive.` } if (error instanceof ConnectionError) { - return `Connection error: ${error.message}\nRun 'brv restart' if the daemon is unresponsive.` + const connectionError = error as {message?: string} + return `Connection error: ${connectionError.message ?? 'unknown error'}\nRun 'brv restart' if the daemon is unresponsive.` } // Business errors from transport handlers (auth, validation, etc.) if (error instanceof TransportRequestError) { + const transportError = error as {code?: unknown; message?: string} // Strip the " for event '...'" suffix that TransportRequestError appends - const baseMessage = error.message.replace(/ for event '[^']+'$/, '') + const baseMessage = (transportError.message ?? 'Transport request failed').replace(/ for event '[^']+'$/, '') - if (error.code && typeof error.code === 'string') { - return USER_FRIENDLY_MESSAGES[error.code] ?? baseMessage + if (typeof transportError.code === 'string') { + return USER_FRIENDLY_MESSAGES[transportError.code] ?? baseMessage } return baseMessage @@ -253,6 +256,10 @@ export function formatConnectionError(error: unknown, providerContext?: Provider const lowerMessage = message.toLowerCase() if (lowerMessage.includes('401') || lowerMessage.includes('unauthorized')) { + if (isNonByteRoverProvider(providerContext)) { + return formatProviderUnauthorizedError(providerContext) + } + return "Authentication required for cloud sync. Run 'brv login' to connect your account." } @@ -263,6 +270,11 @@ export function formatConnectionError(error: unknown, providerContext?: Provider return `Unexpected error: ${message}` } +function isNonByteRoverProvider(providerContext?: ProviderErrorContext): boolean { + const provider = providerContext?.activeProvider + return Boolean(provider && provider !== 'byterover') +} + function formatApiKeyError(providerContext?: ProviderErrorContext): string { const provider = providerContext?.activeProvider ?? '' const model = providerContext?.activeModel @@ -277,3 +289,18 @@ function formatApiKeyError(providerContext?: ProviderErrorContext): string { ' See all options: brv providers --help' ) } + +function formatProviderUnauthorizedError(providerContext?: ProviderErrorContext): string { + const provider = providerContext?.activeProvider ?? '' + const model = providerContext?.activeModel + const currentInfo = model ? `Provider: ${provider} Model: ${model}\n\n` : `Provider: ${provider}\n\n` + const modelFlag = model ? ` --model ${model}` : ' --model ' + const baseUrlFlag = provider === 'openai-compatible' ? ' --base-url ' : '' + + return ( + `LLM provider request was unauthorized.\n${currentInfo}` + + 'Reconnect the active provider with a valid API key/token and base URL:\n' + + ` brv providers connect ${provider}${baseUrlFlag} --api-key ${modelFlag}\n\n` + + 'Cloud sync login is not required for local query/curate unless using the ByteRover provider.' + ) +} diff --git a/src/server/infra/process/curate-log-handler.ts b/src/server/infra/process/curate-log-handler.ts index 1ad84475c..ec6877976 100644 --- a/src/server/infra/process/curate-log-handler.ts +++ b/src/server/infra/process/curate-log-handler.ts @@ -4,6 +4,7 @@ import type {TaskInfo} from '../../core/domain/transport/task-info.js' import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js' import type {ICurateLogStore} from '../../core/interfaces/storage/i-curate-log-store.js' +import {formatBlockedCurationMessage, isBlockedCurationResponse} from '../../utils/curate-outcome.js' import {extractCurateOperations} from '../../utils/curate-result-parser.js' import {getProjectDataDir} from '../../utils/path-utils.js' import {transportLog} from '../../utils/process-logger.js' @@ -166,6 +167,24 @@ export class CurateLogHandler implements ITaskLifecycleHook { const store = this.getOrCreateStore(state.projectPath) + if (state.operations.length === 0 && isBlockedCurationResponse(result)) { + const updated: CurateLogEntry = { + ...state.entry, + completedAt: Date.now(), + error: formatBlockedCurationMessage(result), + operations: state.operations, + status: 'error', + summary: computeSummary(state.operations), + } + + await store.save(updated).catch((error: unknown) => { + transportLog( + `CurateLogHandler: failed to save blocked entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + return + } + const updated: CurateLogEntry = { ...state.entry, completedAt: Date.now(), diff --git a/src/server/utils/curate-outcome.ts b/src/server/utils/curate-outcome.ts new file mode 100644 index 000000000..064fa76a8 --- /dev/null +++ b/src/server/utils/curate-outcome.ts @@ -0,0 +1,20 @@ +const CURATION_BLOCKED_PATTERNS = [ + /\bcontext curation blocked\b/i, + /\bi am blocked\b.{0,160}\b(?:curat(?:e|ion|ing)|code_exec|tool(?:ing|s)?|rlm|sandbox|session)\b/i, + /\b(?:the curation agent|this curation)\b.{0,120}\b(?:blocked|could not|cannot|can't|unable to)\b/i, + /\b(?:cannot|can't|could not|unable to)\b.{0,120}\b(?:complete|perform|do|run|access|verify|use|call)\b.{0,120}\b(?:proper\s+)?(?:rlm\s+)?curat(?:e|ion|ing)\b/i, + /\b(?:required|necessary)\b.{0,80}\b(?:code_exec|tooling|tools?)\b.{0,80}\b(?:not|missing|unavailable|exposed|provided|registered)\b/i, + /\b(?:code_exec|tooling|tools?)\b.{0,80}\b(?:not|missing|unavailable|exposed|provided|registered)\b.{0,80}\b(?:in this session|for (?:this|the) curation|to complete)\b/i, +] + +export function isBlockedCurationResponse(response?: string): boolean { + if (!response?.trim()) return false + return CURATION_BLOCKED_PATTERNS.some((pattern) => pattern.test(response)) +} + +export function formatBlockedCurationMessage(response?: string): string { + const firstLine = response?.split('\n').map((line) => line.trim()).find(Boolean) + if (!firstLine) return 'Context curation blocked' + const suffix = firstLine.length > 240 ? `${firstLine.slice(0, 237)}...` : firstLine + return `Context curation blocked: ${suffix}` +} diff --git a/test/commands/curate.test.ts b/test/commands/curate.test.ts index eb1137f1a..baf86738e 100644 --- a/test/commands/curate.test.ts +++ b/test/commands/curate.test.ts @@ -336,7 +336,35 @@ describe('Curate Command', () => { * @param pendingCount - When provided, fires review:notify before task:completed. * The server broadcasts this event when curate completes with operations requiring review. */ - function simulateTaskCompletion(toolResults: unknown[], pendingCount?: number): void { + const defaultProviderConfig = { + activeModel: 'openclaw/main', + activeProvider: 'openai-compatible', + } + + function simulateTaskError( + error: {code?: string; message: string}, + providerConfig: {activeModel?: string; activeProvider?: string} = defaultProviderConfig, + ): void { + const eventHandlers = new Map void>() + + ;(mockClient.on as sinon.SinonStub).callsFake((event: string, handler: (data: unknown) => void) => { + eventHandlers.set(event, handler) + return () => {} + }) + + ;(mockClient.requestWithAck as sinon.SinonStub).callsFake(async (event: string, data: unknown) => { + if (event === 'state:getProviderConfig') return providerConfig + + const {taskId} = data as {taskId: string} + setImmediate(() => { + eventHandlers.get('task:error')?.({error, logId: 'log-1', taskId}) + }) + + return {logId: 'log-1'} + }) + } + + function simulateTaskCompletion(toolResults: unknown[], pendingCount?: number, result = 'done'): void { const eventHandlers = new Map void>() ;(mockClient.on as sinon.SinonStub).callsFake((event: string, handler: (data: unknown) => void) => { @@ -372,7 +400,7 @@ describe('Curate Command', () => { }) } - const completedPayload: Record = {logId: 'log-1', taskId} + const completedPayload: Record = {logId: 'log-1', result, taskId} if (pendingCount !== undefined && pendingCount > 0) { completedPayload.pendingReviewCount = pendingCount } @@ -385,6 +413,55 @@ describe('Curate Command', () => { } describe('pending review output', () => { + it('should sanitize provider auth failures in JSON task:error output', async () => { + simulateTaskError({message: 'Unauthorized: bearer SECRET_VALUE_SHOULD_NOT_LEAK'}) + + await createJsonCommand('test context').run() + + const json = parseLastJsonLine() + expect(json.success).to.be.false + expect(json.data).to.include({event: 'error', status: 'error'}) + expect(json.data).to.have.property('message').that.includes('LLM provider request was unauthorized') + expect(json.data.message).to.not.include('SECRET_VALUE_SHOULD_NOT_LEAK') + expect(json.data.message).to.not.include('brv login') + }) + + it('should not print success when completed task result says curation was blocked', async () => { + simulateTaskCompletion( + [], + undefined, + 'I am blocked from doing the RLM curation properly because the required ByteRover `code_exec` tool is not exposed in this session.', + ) + + await createCommand('test context').run() + + expect(loggedMessages.some((m) => m.includes('Context curated successfully'))).to.be.false + expect(loggedMessages.some((m) => m.includes('Context curation blocked'))).to.be.true + }) + + it('should output JSON failure when completed task result says zero-op curation was blocked', async () => { + simulateTaskCompletion( + [], + undefined, + 'The curation agent could not complete proper RLM curation because required code_exec tooling was not exposed.', + ) + + await createJsonCommand('test context').run() + + const json = parseLastJsonLine() + expect(json.success).to.be.false + expect(json.data).to.include({event: 'completed', status: 'error'}) + expect(json.data).to.have.property('message').that.includes('Context curation blocked') + }) + + it('should report a genuine zero-op completion truthfully', async () => { + simulateTaskCompletion([], undefined, 'No durable project context changes were needed.') + + await createCommand('test context').run() + + expect(loggedMessages.some((m) => m.includes('Context curated successfully'))).to.be.false + expect(loggedMessages.some((m) => m.includes('No context changes applied'))).to.be.true + }) it('should print review summary for high-impact pending ops', async () => { simulateTaskCompletion( diff --git a/test/commands/query.test.ts b/test/commands/query.test.ts index 65fde23bb..5f81f2860 100644 --- a/test/commands/query.test.ts +++ b/test/commands/query.test.ts @@ -165,6 +165,46 @@ describe('Query Command', () => { }) }) + describe('json error sanitization', () => { + it('should sanitize provider auth failures from task:error events', async () => { + const eventHandlers: Map void>> = new Map() + ;(mockClient.on as sinon.SinonStub).callsFake((event: string, handler: (data: unknown) => void) => { + if (!eventHandlers.has(event)) eventHandlers.set(event, []) + eventHandlers.get(event)!.push(handler) + return () => {} + }) + ;(mockClient.requestWithAck as sinon.SinonStub).callsFake(async (event: string, payload: {taskId: string}) => { + if (event === 'state:getProviderConfig') { + return {activeModel: 'openclaw/main', activeProvider: 'openai-compatible'} + } + if (event === 'billing:resolve') return {} + if (event === 'config:getEnvironment') return {} + + setTimeout(() => { + const handlers = eventHandlers.get('task:error') + if (handlers) { + for (const handler of handlers) { + handler({ + error: {message: 'Unauthorized: bearer SECRET_VALUE_SHOULD_NOT_LEAK'}, + taskId: payload.taskId, + }) + } + } + }, 10) + return {taskId: payload.taskId} + }) + + await createJsonCommand('How does auth work?').run() + + const json = parseJsonOutput().at(-1)! + expect(json.success).to.be.false + expect(json.data).to.have.property('message').that.includes('LLM provider request was unauthorized') + expect(json.data.message).to.include('openai-compatible') + expect(json.data.message).to.not.include('SECRET_VALUE_SHOULD_NOT_LEAK') + expect(json.data.message).to.not.include('brv login') + }) + }) + // ==================== Task Submission ==================== describe('task submission', () => { diff --git a/test/unit/infra/executor/curate-executor.test.ts b/test/unit/infra/executor/curate-executor.test.ts index 95d718075..fb590b913 100644 --- a/test/unit/infra/executor/curate-executor.test.ts +++ b/test/unit/infra/executor/curate-executor.test.ts @@ -153,6 +153,31 @@ describe('CurateExecutor (regression)', () => { }) describe('background queue drain', () => { + it('returns blocked-looking responses to the caller and still cleans up the task session', async () => { + const createTaskSession = stub().resolves('session-id') + const deleteTaskSession = stub().resolves() + const executeOnSession = stub().resolves( + 'I am blocked from doing the RLM curation properly because the required ByteRover `code_exec` tool is not exposed in this session.', + ) + + const agent = { + ...buildSplitTestAgent(), + createTaskSession, + deleteTaskSession, + executeOnSession, + } as unknown as ICipherAgent + + const executor = new CurateExecutor() + const response = await executor.executeWithAgent(agent, { + clientCwd: '/workspace', + content: 'capture auth knowledge', + taskId: 'task-123', + }) + + expect(response).to.include('required ByteRover `code_exec` tool is not exposed') + expect(deleteTaskSession.calledOnceWithExactly('session-id')).to.be.true + }) + it('waits for background work before returning curate results', async () => { const createTaskSession = stub().resolves('session-id') const deleteTaskSession = stub().resolves() diff --git a/test/unit/infra/process/curate-log-handler.test.ts b/test/unit/infra/process/curate-log-handler.test.ts index f3b8defd6..b44fffed2 100644 --- a/test/unit/infra/process/curate-log-handler.test.ts +++ b/test/unit/infra/process/curate-log-handler.test.ts @@ -428,6 +428,22 @@ describe('CurateLogHandler', () => { } as never) }) + it('should save blocked zero-operation completion as an error entry', async () => { + handler = new CurateLogHandler(() => store) + await handler.onTaskCreate(makeTask()) + + await handler.onTaskCompleted( + 'task-abc', + 'The curation agent could not complete proper RLM curation because required code_exec tooling was not exposed.', + makeTask(), + ) + + const errorEntry = store.save.secondCall.args[0] as {error?: string; operations: unknown[]; status: string} + expect(errorEntry.status).to.equal('error') + expect(errorEntry.operations).to.have.lengthOf(0) + expect(errorEntry.error).to.include('Context curation blocked') + }) + it('should save completed entry with correct status and operations', async () => { await handler.onTaskCompleted('task-abc', 'Great job!', makeTask()) diff --git a/test/unit/oclif/lib/daemon-client-error.test.ts b/test/unit/oclif/lib/daemon-client-error.test.ts index 468080ee9..e3977ef02 100644 --- a/test/unit/oclif/lib/daemon-client-error.test.ts +++ b/test/unit/oclif/lib/daemon-client-error.test.ts @@ -16,6 +16,17 @@ describe('formatConnectionError — task error handling', () => { expect(result).to.not.include('API key is missing or invalid') }) + it('should keep brv login guidance for cloud auth errors', () => { + const error = Object.assign(new Error('Not authenticated'), { + code: TaskErrorCode.NOT_AUTHENTICATED, + }) + + const result = formatConnectionError(error) + + expect(result).to.include('Cloud sync') + expect(result).to.include('brv login') + }) + it('should return raw backend message for unmapped task error codes (e.g. ERR_TASK_EXECUTION)', () => { const backendMessage = "You've reached your free tier daily request limit (0/0 in the last 24 hours). " + @@ -65,12 +76,40 @@ describe('formatConnectionError — task error handling', () => { expect(result).to.include('API key is missing or invalid') }) - it('should still text-match "401" for plain errors without code', () => { + it('should still text-match "401" for plain errors without code and provider context', () => { + const error = new Error('Request failed with status code 401') + + const result = formatConnectionError(error, { + activeModel: 'openclaw/main', + activeProvider: 'openai-compatible', + }) + + expect(result).to.include('LLM provider request was unauthorized') + expect(result).to.include('openai-compatible') + expect(result).to.include('openclaw/main') + expect(result).to.not.include('brv login') + }) + + it('should not leak secrets from provider auth failures', () => { + const secret = 'SECRET_VALUE_SHOULD_NOT_LEAK' + const error = new Error(`Unauthorized: bearer ${secret}`) + + const result = formatConnectionError(error, { + activeModel: 'openclaw/main', + activeProvider: 'openai-compatible', + }) + + expect(result).to.include('LLM provider request was unauthorized') + expect(result).to.not.include(secret) + }) + + it('should keep cloud login guidance for plain 401 errors without provider context', () => { const error = new Error('Request failed with status code 401') const result = formatConnectionError(error) expect(result).to.include('Authentication required') + expect(result).to.include('brv login') }) }) }) diff --git a/test/unit/server/utils/curate-outcome.test.ts b/test/unit/server/utils/curate-outcome.test.ts new file mode 100644 index 000000000..2a90a548f --- /dev/null +++ b/test/unit/server/utils/curate-outcome.test.ts @@ -0,0 +1,20 @@ +import {expect} from 'chai' + +import {formatBlockedCurationMessage, isBlockedCurationResponse} from '../../../../src/server/utils/curate-outcome.js' + +describe('curate-outcome', () => { + it('detects blocked RLM/tooling responses', () => { + const response = + 'The curation agent could not complete proper RLM curation because required code_exec tooling was not exposed.' + + expect(isBlockedCurationResponse(response)).to.be.true + expect(formatBlockedCurationMessage(response)).to.include('Context curation blocked') + }) + + it('does not false-positive on successful no-op explanations about prior tooling failures', () => { + const response = + 'No durable project context changes were needed because the note only documents a failed sandbox migration from last week.' + + expect(isBlockedCurationResponse(response)).to.be.false + }) +})