Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"profile:repl:scroll:cpu": "bun build/replProfile.mjs cpu scroll",
"profile:repl:long-history:cpu": "bun build/replProfile.mjs cpu long-history",
"profile:repl:long-history:heap": "bun build/replProfile.mjs heap long-history",
"test:contracts": "bun test src/QueryEngine.test.ts src/utils/sessionLifecycle.test.ts src/utils/startupPrefetch.test.ts src/utils/startupPrefetchPolicy.test.ts src/utils/citcWorkspaceSource.test.ts src/utils/crypto.test.ts src/utils/envUtils.test.ts src/utils/debug.test.ts src/utils/json.test.ts src/utils/glob.test.ts src/utils/glob-defaults.test.ts src/utils/themeOnboardingOutput.test.ts src/utils/startupPromptOutput.test.ts src/utils/claudemdRepoBoundary.test.ts src/utils/fullscreen.tmux.test.ts src/constants/repoInspectionPrompt.test.ts src/constants/repoExposureAudit.test.ts src/constants/product.test.ts src/constants/productIdentityAudit.test.ts src/services/api/openAICompatWsV2Native.test.ts src/services/api/inferenceRequestShapeModeContract.test.ts src/services/api/claude.verifyApiKey.test.ts src/remote/runtimeMatrix.test.ts src/ink/events/input-event.test.ts src/keybindings/resolver.test.ts src/tools/REPLTool/constants.test.ts src/tools/BashTool/BashTool.test.ts src/tools/GrepTool/GrepTool.budget-contract.test.ts src/tools/toolPolicy.test.ts src/tools/toolPromptRouting.test.ts src/tools/FileWriteTool/UI.test.tsx src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.test.tsx src/ink/useTerminalViewport.test.ts src/commands/repaint/repaint.test.ts",
"test:contracts": "bun test src/QueryEngine.test.ts src/utils/sessionLifecycle.test.ts src/utils/startupPrefetch.test.ts src/utils/startupPrefetchPolicy.test.ts src/utils/citcWorkspaceSource.test.ts src/utils/crypto.test.ts src/utils/envUtils.test.ts src/utils/debug.test.ts src/utils/json.test.ts src/utils/glob.test.ts src/utils/glob-defaults.test.ts src/utils/themeOnboardingOutput.test.ts src/utils/startupPromptOutput.test.ts src/utils/claudemdRepoBoundary.test.ts src/utils/fullscreen.tmux.test.ts src/utils/staticRender.test.tsx src/components/Feedback.submit.test.ts src/constants/repoInspectionPrompt.test.ts src/constants/repoExposureAudit.test.ts src/constants/product.test.ts src/constants/productIdentityAudit.test.ts src/services/api/openAICompatWsV2Native.test.ts src/services/api/inferenceRequestShapeModeContract.test.ts src/services/api/claude.verifyApiKey.test.ts src/remote/runtimeMatrix.test.ts src/ink/events/input-event.test.ts src/keybindings/resolver.test.ts src/tools/REPLTool/constants.test.ts src/tools/BashTool/BashTool.test.ts src/tools/GrepTool/GrepTool.budget-contract.test.ts src/tools/toolPolicy.test.ts src/tools/toolPromptRouting.test.ts src/tools/FileWriteTool/UI.test.tsx src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.test.tsx src/ink/useTerminalViewport.test.ts src/commands/repaint/repaint.test.ts src/commands/export/export.test.tsx",
"test:inference-contracts": "bun test src/services/api/openAICompatInferenceClient.test.ts src/services/api/openAICompatInferenceReceiptReplay.test.ts",
"test:snapshots": "bun test src/ink/replVisibleScreenContract.test.tsx src/ink/replTranscriptScreenContract.test.tsx src/ink/replToolTranscriptScreenContract.test.tsx src/ink/replToolResultMountedContract.test.tsx src/components/messages/messagesToolResultTranscriptContract.test.tsx src/components/ThemePicker.renderSnapshot.test.tsx src/components/LogoV2.renderSnapshot.test.tsx src/components/TeleportProgress.renderSnapshot.test.tsx src/components/ResumeTask.renderSnapshot.test.tsx src/components/TeleportResumeWrapper.renderSnapshot.test.tsx src/ink/replSubmitAssistantTurn.test.tsx",
"replay:openai-compat-receipts": "bun build/openAICompatReceiptReplay.mjs",
Expand Down
63 changes: 63 additions & 0 deletions src/commands/export/export.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { runWithCwdOverride } from '../../utils/cwd.js'
import { createUserMessage } from '../../utils/messages.js'
import { call } from './export.js'

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out after ${ms}ms`)),
ms,
)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}

describe('/export command', () => {
const tempDirs: string[] = []

afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir) rmSync(dir, { recursive: true, force: true })
}
})

test('writes filename exports without hanging', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'ncode-export-'))
tempDirs.push(cwd)
const doneMessages: string[] = []
const targetPath = join(cwd, 'conversation.txt')

const node = await runWithCwdOverride(cwd, () =>
withTimeout(
call(
message => {
if (message) doneMessages.push(message)
},
{
messages: [createUserMessage({ content: 'hello export' })],
options: { tools: [] },
} as never,
'conversation.txt',
),
1000,
),
)

expect(node).toBeNull()
expect(doneMessages).toEqual([`Conversation exported to: ${targetPath}`])
expect(existsSync(targetPath)).toBe(true)
expect(readFileSync(targetPath, 'utf8')).toContain('hello export')
})
})
2 changes: 1 addition & 1 deletion src/commands/export/export.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { join } from 'path';
import React from 'react';
import { ExportDialog } from '../../components/ExportDialog.js';
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { Message } from '../../types/message.js';
Expand Down Expand Up @@ -84,6 +83,7 @@ export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContex
}

// Return the dialog component when no args provided
const { ExportDialog } = await import('../../components/ExportDialog.js');
return <ExportDialog content={content} defaultFilename={defaultFilename} onDone={result => {
onDone(result.message);
}} />;
Expand Down
152 changes: 152 additions & 0 deletions src/components/Feedback.submit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { getAuthRuntime } from '../auth/runtime/AuthRuntime.js'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
import type { ResolvedAuthSession } from '../auth/runtime/types.js'
import { submitFeedback, type FeedbackData } from './Feedback.js'

const originalAxiosPost = axios.post
const originalMacro = globalThis.MACRO
const originalPlatformBaseUrl = process.env.NOUMENA_PLATFORM_BASE_URL
const originalDisableNonessentialTraffic = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC

function makeSession(
overrides: Partial<ResolvedAuthSession> = {},
): ResolvedAuthSession {
return {
principalKind: 'noumena_account',
principalSource: 'managed_oauth',
sessionState: 'usable',
headersKind: 'bearer',
providerAuthKind: 'noumena_first_party',
providerPlan: {
mode: 'noumena_managed',
source: 'managed_principal',
staticKeyEnvVarName: null,
},
isInteractive: true,
canRefresh: true,
canReauthenticateInteractively: true,
identity: {
email: 'user@noumena.net',
accountUuid: 'acct-1',
organizationUuid: 'org-1',
organizationName: 'Noumena',
},
subscription: {
subscriptionName: 'Noumena Max',
subscriptionType: 'max',
rateLimitTier: 'tier-1',
},
scopes: ['user:inference'],
hasUsableToken: true,
hasUsableApiKey: false,
accessToken: 'managed-token',
accessTokenExpiresAt: Date.now() + 60_000,
refreshTokenPresent: true,
apiKey: null,
rawAuthTokenSource: 'noumena.com',
rawApiKeySource: null,
recoveryAction: 'none',
recoveryMessage: null,
sourceDetails: {
usedLegacyCompat: false,
usedEnvVar: false,
usedFileDescriptor: false,
usedHelper: false,
},
...overrides,
}
}

function makeFeedbackData(): FeedbackData {
return {
latestAssistantMessageId: null,
message_count: 0,
datetime: '2026-07-01T00:00:00.000Z',
description: 'export hangs and feedback failed',
platform: 'linux',
gitRepo: false,
version: '0.0.0-test',
transcript: [],
}
}

describe('submitFeedback', () => {
const runtime = getAuthRuntime() as {
resolveSession: (options?: { allowRefresh?: boolean }) => Promise<ResolvedAuthSession>
getCurrentSession: () => ResolvedAuthSession
}
const originalResolveSession = runtime.resolveSession
const originalGetCurrentSession = runtime.getCurrentSession

beforeEach(() => {
process.env.NOUMENA_PLATFORM_BASE_URL = 'https://api.noumena.test'
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
;(globalThis as { MACRO?: Record<string, unknown> }).MACRO = {
VERSION: '0.0.0-test',
}
runtime.resolveSession = mock(async () => makeSession())
runtime.getCurrentSession = mock(() => makeSession())
axios.post = originalAxiosPost
})

afterEach(() => {
if (originalPlatformBaseUrl === undefined) {
delete process.env.NOUMENA_PLATFORM_BASE_URL
} else {
process.env.NOUMENA_PLATFORM_BASE_URL = originalPlatformBaseUrl
}
if (originalDisableNonessentialTraffic === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
} else {
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalDisableNonessentialTraffic
}
;(globalThis as { MACRO?: unknown }).MACRO = originalMacro
runtime.resolveSession = originalResolveSession
runtime.getCurrentSession = originalGetCurrentSession
axios.post = originalAxiosPost
mock.restore()
})

test('submits to the platform feedback collector with managed auth headers', async () => {
let capturedRequest:
| {
url: string
body: unknown
options?: { headers?: Record<string, string> }
}
| null = null
const feedbackData = makeFeedbackData()

axios.post = (async (url: string, body: unknown, options?: { headers?: Record<string, string> }) => {
capturedRequest = { url, body, options }
return { status: 200, data: { feedback_id: 'fb-1' } }
}) as typeof axios.post

expect(await submitFeedback(feedbackData)).toEqual({
success: true,
feedbackId: 'fb-1',
})
expect(capturedRequest?.url).toBe('https://api.noumena.test/api/ncode_feedback')
expect(capturedRequest?.body).toEqual({ content: JSON.stringify(feedbackData) })
expect(capturedRequest?.options?.headers).toMatchObject({
Authorization: 'Bearer managed-token',
'anthropic-beta': OAUTH_BETA_HEADER,
})
expect(runtime.resolveSession).toHaveBeenCalledWith({ allowRefresh: true })
})

test('fails closed when the collector route is missing', async () => {
axios.post = (async () => {
throw {
isAxiosError: true,
response: { status: 404, data: 'not found' },
}
}) as typeof axios.post

expect(await submitFeedback(makeFeedbackData())).toEqual({
success: false,
})
})
})
10 changes: 6 additions & 4 deletions src/components/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ type Props = {
};
};
type Step = 'userInput' | 'consent' | 'submitting' | 'done';
type FeedbackData = {
export type FeedbackData = {
// latestAssistantMessageId is the message ID from the latest main model call
latestAssistantMessageId: string | null;
message_count: number;
Expand Down Expand Up @@ -248,7 +248,7 @@ export function Feedback({
// Stay on userInput step so user can retry with their content preserved
setStep('userInput');
}
}, [description, envInfo.isGit, messages]);
}, [backgroundTasks, description, envInfo.isGit, messages]);

// Handle cancel - this will be called by Dialog's automatic Esc handling
const handleCancel = useCallback(() => {
Expand All @@ -258,6 +258,7 @@ export function Feedback({
onDone('Error submitting feedback / bug report', {
display: 'system'
});

} else {
onDone('Feedback / bug report submitted', {
display: 'system'
Expand Down Expand Up @@ -288,6 +289,7 @@ export function Feedback({
onDone('Error submitting feedback / bug report', {
display: 'system'
});

} else {
onDone('Feedback / bug report submitted', {
display: 'system'
Expand Down Expand Up @@ -516,7 +518,7 @@ function sanitizeAndLogError(err: unknown): void {
logError(new Error(errorString));
}
}
async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{
export async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{
success: boolean;
feedbackId?: string;
isZdrOrg?: boolean;
Expand All @@ -538,7 +540,7 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise
'User-Agent': getUserAgent(),
...authResult.headers
};
const response = await axios.post(buildNoumenaPlatformUrl('/api/claude_cli_feedback'), {
const response = await axios.post(buildNoumenaPlatformUrl('/api/ncode_feedback'), {
content: jsonStringify(data)
}, {
headers,
Expand Down
10 changes: 5 additions & 5 deletions src/ink/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export default class Ink {
private readonly stylePool: StylePool;
private charPool: CharPool;
private hyperlinkPool: HyperlinkPool;
private exitPromise?: Promise<void>;
private exitPromise: Promise<void>;
private restoreConsole?: () => void;
private restoreStderr?: () => void;
private readonly unsubscribeTTYHandlers?: () => void;
Expand Down Expand Up @@ -202,6 +202,10 @@ export default class Ink {
} | null = null;
constructor(private readonly options: Options) {
autoBind(this);
this.exitPromise = new Promise((resolve, reject) => {
this.resolveExitPromise = resolve;
this.rejectExitPromise = reject;
});
if (this.options.patchConsole) {
this.restoreConsole = this.patchConsole();
this.restoreStderr = this.patchStderr();
Expand Down Expand Up @@ -1685,10 +1689,6 @@ export default class Ink {
}
}
async waitUntilExit(): Promise<void> {
this.exitPromise ||= new Promise((resolve, reject) => {
this.resolveExitPromise = resolve;
this.rejectExitPromise = reject;
});
return this.exitPromise;
}
resetLineCount(): void {
Expand Down
1 change: 1 addition & 0 deletions src/ink/log-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import {
analyzeFillableSpaceGap,
type Cell,
cellAt,
CellWidth,
diffEach,
createScreen,
Expand Down
3 changes: 3 additions & 0 deletions src/tools/toolPromptRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it, mock } from 'bun:test'

process.env.NOUMENA_API_KEY ??= 'test-key-for-hermetic-contracts'

const actualModel = await import(import.meta.resolve('../utils/model/model.ts'))

mock.module(import.meta.resolve('../utils/model/model.js'), () => ({
...actualModel,
getMainLoopModel: () => 'claude-sonnet-4-6',
getSmallFastModel: () => 'claude-haiku-4-5',
}))
Expand Down
32 changes: 32 additions & 0 deletions src/utils/staticRender.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test'
import React from 'react'
import { Text } from '../ink.js'
import { renderToString } from './staticRender.js'

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out after ${ms}ms`)),
ms,
)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}

describe('static renderer', () => {
test('resolves after the render tree exits', async () => {
const output = await withTimeout(
renderToString(<Text>static render complete</Text>, 80),
1000,
)

expect(output).toContain('static render complete')
})
})
Loading