cc-haha/src/server/__tests__/conversation-service.test.ts
程序员阿江(Relakkes) de52656bb2 fix(server): drop CLI messages replayed after a reconnect
Opening a long-finished session showed it "start talking again": dozens
of `已思考` bubbles streaming in, nothing rendered between them. Nothing
was actually re-run — the transcript stops at the moment the turn ended
and the trace holds only the original 13 API calls. The whole wall is a
replay of output that had already been rendered hours earlier.

The source is not in this server at all. `WebSocketTransport` (inherited
upstream, used for the CLI's `--sdk-url` connection) buffers every
outbound message that carries a uuid, and on every successful reconnect
replays that buffer from the start — `onBunOpen` passes an empty
`lastId`, which skips the branch that would evict already-confirmed
messages, and `replayBufferedMessages` deliberately does not clear the
buffer afterwards. It is safe to do that only because the code assumes
"The server deduplicates by UUID". We never implemented that contract:
`X-Last-Request-Id` appears nowhere outside the transport itself, and
the one uuid check in `handler.ts` only guards task-notification
persistence and forwards regardless. So each reconnect pushed the whole
window through untouched.

The transport also detects system sleep explicitly and keeps resetting
its reconnect budget, so a closed laptop guarantees the reconnect rather
than preventing it. The diagnostics for the reported session (pid 58975)
recorded 31 sleep detections and 31 replays of 858 messages each, spread
over the ten hours between the turn ending and the session being opened
— 13 thinking blocks re-delivered 31 times. Across this machine the same
event has fired 10811 times; it has been happening all along.

Only thinking showed it. Replayed user messages are idempotent, replayed
tool calls are upserted by `toolUseId`, replayed tool results are folded
into the tool card and stay invisible, and replayed reply text is caught
by the wake/reconnect guard added in 2a937c6cc. Thinking had nothing:
its UIMessage carries no `transcriptMessageId`, and one cannot be added
— the wire event is `{type, text}` and the hydrated id comes from a
transcript uuid minted at write time, so the two sides share no id.
Every earlier fix keyed on that field, which is why five of them missed
this. `handleSdkPayload` now skips uuids it has already processed, which
covers replayed partial-message stream events too — that is what the
wall was actually made of — and does so without touching the WS protocol
or the transcript shape.

The renderer keeps a second line of defence for whole-block replays that
might arrive by some other route: a thinking chunk equal to an existing
block is dropped, blank chunks no longer open an empty bubble, and
`appendAssistantTextMessage` also rejects text identical to a hydrated
reply. Equality, not substring — a streamed delta is a fragment and is
almost always a substring of some earlier reply, so a substring test
would swallow normal output. `tool_use_complete` now flushes pending
text the way the streaming path's `content_start` already does, so the
two paths stop disagreeing about where a reply ends.

The CLI's empty `lastId` is left alone; fixing it needs the server to
answer with `x-last-request-id`. Note that only the Bun branch replays —
the Node branch calls `replayBufferedMessages` inside a check for an
upgrade header that the `ws` package removed in v3, so it never fires.
Any regression test written under Node would pass without exercising it.

Tested: bun run check:server (232 files, 2492 tests)
Tested: bun run check:desktop (275 files, 3383 tests)
Tested: bun run check:chat-contract (183 tests)
Not-tested: real sleep/wake cycle against a packaged desktop build.
2026-07-29 11:53:23 +08:00

1466 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import {
buildConversationCliSpawnOptions,
ConversationService,
DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
} from '../services/conversationService.js'
import { ProviderService } from '../services/providerService.js'
import { updateTraceCaptureSettings } from '../services/traceCaptureService.js'
import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js'
describe('ConversationService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalApiKey: string | undefined
let originalAuthToken: string | undefined
let originalBaseUrl: string | undefined
let originalModel: string | undefined
let originalEntrypoint: string | undefined
let originalOAuthToken: string | undefined
let originalProviderManagedByHost: string | undefined
let originalLocalAccessToken: string | undefined
let originalDiagnosticsFile: string | undefined
let originalAttributionHeader: string | undefined
let originalDisableExperimentalBetas: string | undefined
let originalResumeInterruptedTurn: string | undefined
let originalTraceApiCalls: string | undefined
let originalTraceProviderId: string | undefined
let originalTraceProviderName: string | undefined
let originalTraceProviderFormat: string | undefined
let originalHome: string | undefined
let originalPath: string | undefined
let originalShell: string | undefined
let originalZdotdir: string | undefined
let originalDisableTerminalShellEnv: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalApiKey = process.env.ANTHROPIC_API_KEY
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
originalModel = process.env.ANTHROPIC_MODEL
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
originalDisableExperimentalBetas = process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
originalTraceApiCalls = process.env.CC_HAHA_TRACE_API_CALLS
originalTraceProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID
originalTraceProviderName = process.env.CC_HAHA_TRACE_PROVIDER_NAME
originalTraceProviderFormat = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
originalHome = process.env.HOME
originalPath = process.env.PATH
originalShell = process.env.SHELL
originalZdotdir = process.env.ZDOTDIR
originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.ANTHROPIC_API_KEY = 'stale-parent-api-key'
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
process.env.ANTHROPIC_MODEL = 'test-model'
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'inherited-parent-oauth-token'
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
// buildChildEnv injects it or not without interference from the shell env.
delete process.env.CLAUDE_CODE_ENTRYPOINT
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
delete process.env.CC_HAHA_TRACE_API_CALLS
delete process.env.CC_HAHA_TRACE_PROVIDER_ID
delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
resetTerminalShellEnvironmentCacheForTests()
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = originalApiKey
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
if (originalBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL
else process.env.ANTHROPIC_BASE_URL = originalBaseUrl
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
else process.env.ANTHROPIC_MODEL = originalModel
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint
if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost
if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken
if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile
if (originalAttributionHeader === undefined) delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
else process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttributionHeader
if (originalDisableExperimentalBetas === undefined) delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
else process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = originalDisableExperimentalBetas
if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn
if (originalTraceApiCalls === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS
else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceApiCalls
if (originalTraceProviderId === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_ID
else process.env.CC_HAHA_TRACE_PROVIDER_ID = originalTraceProviderId
if (originalTraceProviderName === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_NAME
else process.env.CC_HAHA_TRACE_PROVIDER_NAME = originalTraceProviderName
if (originalTraceProviderFormat === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT
else process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = originalTraceProviderFormat
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
if (originalPath === undefined) delete process.env.PATH
else process.env.PATH = originalPath
if (originalShell === undefined) delete process.env.SHELL
else process.env.SHELL = originalShell
if (originalZdotdir === undefined) delete process.env.ZDOTDIR
else process.env.ZDOTDIR = originalZdotdir
if (originalDisableTerminalShellEnv === undefined) delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
else process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
resetTerminalShellEnvironmentCacheForTests()
await fs.rm(tmpDir, { recursive: true, force: true })
})
async function writeFakeZsh(filePath: string) {
await fs.writeFile(
filePath,
[
'#!/bin/sh',
'command=',
'while [ "$#" -gt 0 ]; do',
' if [ "$1" = "-c" ]; then',
' shift',
' command="$1"',
' break',
' fi',
' shift',
'done',
'if [ -f "$HOME/.zshrc" ]; then',
' . "$HOME/.zshrc" </dev/null >/dev/null 2>/dev/null || true',
'fi',
'exec /bin/sh -c "$command"',
'',
].join('\n'),
{ mode: 0o755 },
)
}
function installNetworkTestSession(
service: any,
sessionId: string,
sent: string[],
networkDerivedFirstTokenTimeout = true,
) {
const session = {
outputCallbacks: [],
networkRoutingFingerprint: '',
networkDerivedFirstTokenTimeout,
sdkSocket: {
send(line: string) {
sent.push(line)
},
},
pendingOutbound: [],
usesOfficialOAuth: false,
officialOAuthToken: null,
pendingPermissionRequests: new Map(),
}
service.sessions.set(sessionId, session)
return session
}
test('keeps inherited provider env when no desktop provider config exists', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
expect(env.ANTHROPIC_MODEL).toBe('test-model')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'))
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toBe(
`${path.join(tmpDir, 'projects', 'D--workspace-code-myself-code-cc-haha', 'memory')}${path.sep}`,
)
const diagnosticsDirectory = await fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))
expect(diagnosticsDirectory).toBeTruthy()
if (process.platform !== 'win32') {
expect(diagnosticsDirectory.mode & 0o777).toBe(0o700)
}
})
test('omits CLI diagnostics when its managed directory resolves through a symlink', async () => {
if (process.platform === 'win32') return
const unrelatedDir = path.join(tmpDir, 'unrelated-cli-diagnostics')
const unrelatedDiagnosticsDir = path.join(unrelatedDir, 'diagnostics')
await fs.mkdir(unrelatedDiagnosticsDir, { recursive: true, mode: 0o755 })
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.rm(path.join(tmpDir, 'cc-haha'), { recursive: true, force: true })
await fs.symlink(unrelatedDir, path.join(tmpDir, 'cc-haha'), 'dir')
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBeUndefined()
expect((await fs.stat(unrelatedDiagnosticsDir)).mode & 0o777).toBe(0o755)
await expect(fs.stat(path.join(unrelatedDiagnosticsDir, 'cli-diagnostics.jsonl'))).rejects.toThrow()
})
test('buildChildEnv injects stream watchdog + overall max-duration so a trickling provider stream cannot hang the desktop forever (#766)', async () => {
const prev = process.env.CLAUDE_STREAM_MAX_DURATION_MS
delete process.env.CLAUDE_STREAM_MAX_DURATION_MS
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
// Idle watchdog frees a fully-silent stream after 240s...
expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('1')
expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('240000')
// ...but the idle timer is reset by EVERY SSE event, so an upstream that
// trickles content deltas (a large tool_use input_json_delta) just under
// 240s apart keeps it alive forever. The overall-duration cap is NOT reset
// by chunks and is what actually frees that case (#766).
expect(env.CLAUDE_STREAM_MAX_DURATION_MS).toBe('600000')
// Non-streaming fallback stays off — its retry loop also hangs the UI (#766).
expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('1')
} finally {
if (prev === undefined) delete process.env.CLAUDE_STREAM_MAX_DURATION_MS
else process.env.CLAUDE_STREAM_MAX_DURATION_MS = prev
}
})
test('buildChildEnv flushes desktop transcripts before the SDK reports turn completion (#1033)', async () => {
const previous = process.env.CLAUDE_CODE_EAGER_FLUSH
delete process.env.CLAUDE_CODE_EAGER_FLUSH
resetTerminalShellEnvironmentCacheForTests()
try {
const service = new ConversationService() as any
const sdkEnv = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/session?token=test',
)) as Record<string, string>
const nonSdkEnv = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(sdkEnv.CLAUDE_CODE_EAGER_FLUSH).toBe('1')
expect(nonSdkEnv.CLAUDE_CODE_EAGER_FLUSH).toBeUndefined()
process.env.CLAUDE_CODE_EAGER_FLUSH = '0'
resetTerminalShellEnvironmentCacheForTests()
const explicitEnv = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/session?token=test',
)) as Record<string, string>
expect(explicitEnv.CLAUDE_CODE_EAGER_FLUSH).toBe('0')
} finally {
if (previous === undefined) delete process.env.CLAUDE_CODE_EAGER_FLUSH
else process.env.CLAUDE_CODE_EAGER_FLUSH = previous
resetTerminalShellEnvironmentCacheForTests()
}
})
test('buildChildEnv lets caller env override the stream max-duration cap (#766)', async () => {
const prev = process.env.CLAUDE_STREAM_MAX_DURATION_MS
process.env.CLAUDE_STREAM_MAX_DURATION_MS = '120000'
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_STREAM_MAX_DURATION_MS).toBe('120000')
} finally {
if (prev === undefined) delete process.env.CLAUDE_STREAM_MAX_DURATION_MS
else process.env.CLAUDE_STREAM_MAX_DURATION_MS = prev
}
})
test('builds hidden CLI spawn options for desktop session subprocesses', () => {
const env = { CLAUDECODE: '1' }
expect(buildConversationCliSpawnOptions('/workspace/project', env)).toEqual({
cwd: '/workspace/project',
env,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
windowsHide: true,
})
})
test('buildChildEnv pins desktop memory to the current sanitized project directory', async () => {
const service = new ConversationService() as any
const workDir = path.join(tmpDir, 'workspace', 'myself_code', 'claude-code-haha')
await fs.mkdir(workDir, { recursive: true })
const env = (await service.buildChildEnv(workDir)) as Record<string, string>
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toBe(
`${path.join(tmpDir, 'projects', sanitizeMemoryPath(workDir), 'memory')}${path.sep}`,
)
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toContain('myself-code')
expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).not.toContain('myself_code')
})
test.skipIf(process.platform === 'win32')(
'buildChildEnv inherits exported terminal shell variables for desktop CLI sessions',
async () => {
const shellPath = path.join(tmpDir, 'zsh')
const nodeBin = path.join(tmpDir, 'node-bin')
const nvmDir = path.join(tmpDir, '.nvm')
await fs.mkdir(nodeBin, { recursive: true })
await fs.mkdir(nvmDir, { recursive: true })
await writeFakeZsh(shellPath)
await fs.writeFile(
path.join(tmpDir, '.zshrc'),
[
`export NVM_DIR="${nvmDir}"`,
`export PATH="${nodeBin}:$PATH"`,
'',
].join('\n'),
)
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
process.env.HOME = tmpDir
process.env.SHELL = shellPath
process.env.PATH = '/usr/bin:/bin'
delete process.env.ZDOTDIR
resetTerminalShellEnvironmentCacheForTests()
const service = new ConversationService() as any
const env = (await service.buildChildEnv(tmpDir)) as Record<string, string>
expect(env.NVM_DIR).toBe(nvmDir)
expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin)
expect(env.PATH.split(path.delimiter)).toContain('/usr/bin')
},
)
test('strips inherited provider env when desktop provider config exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'providers.json'),
JSON.stringify({ activeId: null, providers: [] }),
'utf-8',
)
const service = new ConversationService() as any
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBeUndefined()
})
test('buildChildEnv injects General network timeout and manual proxy for CLI requests', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 180_000,
proxy: {
mode: 'manual',
url: ' http://127.0.0.1:7890 ',
},
},
}),
'utf-8',
)
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.API_TIMEOUT_MS).toBe('180000')
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7890')
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7890')
expect(env.ALL_PROXY).toBe('http://127.0.0.1:7890')
expect(env.all_proxy).toBe('http://127.0.0.1:7890')
expect(env.NO_PROXY).toContain('127.0.0.1')
expect(env.no_proxy).toContain('localhost')
})
test('buildChildEnv routes system mode through the host-managed dynamic bridge', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17890'
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:17890')
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:17890')
expect(env.ALL_PROXY).toBe('http://127.0.0.1:17890')
expect(env.all_proxy).toBe('http://127.0.0.1:17890')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('buildChildEnv ties the first-token watchdog to the user request timeout so slow prefill is not killed early (#826)', async () => {
const prev = process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
delete process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { aiRequestTimeoutMs: 600_000 } }),
'utf-8',
)
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
// The user's "请求超时" must reach the first-token watchdog, not only the
// SDK client timeout (which on a stream is cleared the moment response
// headers arrive). Otherwise a local/3P model that needs minutes to emit
// its first token gets killed by the 240s idle watchdog no matter how high
// the configured timeout is (#826).
expect(env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS).toBe('600000')
expect(env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS).toBe(env.API_TIMEOUT_MS)
} finally {
if (prev === undefined) delete process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
else process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = prev
}
})
test('buildChildEnv lets caller env override the first-token watchdog (#826)', async () => {
const prev = process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = '900000'
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS).toBe('900000')
} finally {
if (prev === undefined) delete process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
else process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = prev
}
})
test('buildChildEnv injects CLAUDE_CODE_OAUTH_TOKEN when official mode + haha oauth token exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: {} }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'haha-fresh-token',
refreshToken: 'haha-refresh-xxx',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('haha-fresh-token')
})
test('sendMessage updates a running official OAuth CLI token before the user turn', async () => {
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'fresh-after-wake-token',
refreshToken: 'refresh-xxx',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const sent: string[] = []
service.sessions.set('sleep-wake-session', {
proc: {},
outputCallbacks: [],
workDir: tmpDir,
permissionMode: 'default',
sdkToken: 'sdk-token',
sdkSocket: {
send(line: string) {
sent.push(line)
},
},
pendingOutbound: [],
startupPending: false,
startupExitCode: null,
stdoutLines: [],
stderrLines: [],
outputDrain: Promise.resolve(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
usesOfficialOAuth: true,
officialOAuthToken: 'stale-before-sleep-token',
})
const ok = await service.sendMessage('sleep-wake-session', 'hello after wake')
expect(ok).toBe(true)
expect(sent).toHaveLength(2)
expect(JSON.parse(sent[0]!).type).toBe('update_environment_variables')
expect(JSON.parse(sent[0]!).variables.CLAUDE_CODE_OAUTH_TOKEN).toBe('fresh-after-wake-token')
expect(JSON.parse(sent[1]!).type).toBe('user')
})
test('sendMessage hot-applies direct to system routing before the next user turn', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17890'
try {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'direct', url: '' } } }),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'direct-to-system', sent)
await service.refreshNetworkEnvironmentBeforeTurn('direct-to-system', session)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'system', url: '' } } }),
'utf-8',
)
expect(await service.sendMessage('direct-to-system', 'use system proxy')).toBe(true)
expect(sent).toHaveLength(2)
const update = JSON.parse(sent[0]!)
expect(update.type).toBe('update_environment_variables')
expect(update.variables).toMatchObject({
HTTP_PROXY: 'http://127.0.0.1:17890',
HTTPS_PROXY: 'http://127.0.0.1:17890',
http_proxy: 'http://127.0.0.1:17890',
https_proxy: 'http://127.0.0.1:17890',
ALL_PROXY: 'http://127.0.0.1:17890',
all_proxy: 'http://127.0.0.1:17890',
API_TIMEOUT_MS: '600000',
CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS: '600000',
})
expect(update.variables.NO_PROXY).toContain('127.0.0.1')
expect(update.variables.no_proxy).toContain('localhost')
expect(JSON.parse(sent[1]!).type).toBe('user')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('sendMessage hot-applies manual proxy and timeout changes before the next user turn', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 600_000,
proxy: { mode: 'manual', url: 'http://127.0.0.1:17891' },
},
}),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'manual-change', sent)
await service.refreshNetworkEnvironmentBeforeTurn('manual-change', session)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 180_000,
proxy: { mode: 'manual', url: 'http://127.0.0.1:17892' },
},
}),
'utf-8',
)
expect(await service.sendMessage('manual-change', 'use changed proxy')).toBe(true)
expect(sent).toHaveLength(2)
const update = JSON.parse(sent[0]!)
expect(update.type).toBe('update_environment_variables')
expect(update.variables).toMatchObject({
HTTP_PROXY: 'http://127.0.0.1:17892',
HTTPS_PROXY: 'http://127.0.0.1:17892',
ALL_PROXY: 'http://127.0.0.1:17892',
all_proxy: 'http://127.0.0.1:17892',
API_TIMEOUT_MS: '180000',
CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS: '180000',
})
expect(JSON.parse(sent[1]!).type).toBe('user')
})
test('sendMessage does not resend network env when the system bridge fingerprint is unchanged', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17893'
try {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'system', url: '' } } }),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'unchanged-system', sent)
await service.refreshNetworkEnvironmentBeforeTurn('unchanged-system', session)
// PAC/system rules are resolved dynamically inside this stable bridge URL.
// Their changes must not churn the CLI environment between turns.
expect(await service.sendMessage('unchanged-system', 'same bridge')).toBe(true)
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0]!).type).toBe('user')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'haha-token-should-not-be-used',
refreshToken: null,
expiresAt: null,
scopes: [],
subscriptionType: null,
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
test('buildChildEnv injects explicit provider runtime env for session-scoped providers', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Packy',
apiKey: 'provider-key',
baseUrl: 'https://api.packy.example',
apiFormat: 'openai_chat',
models: {
main: 'kimi-k2.6',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('kimi-k2.6')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2.6')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe('desktop-local-secret')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
expect(env.CC_HAHA_TRANSCRIPT_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined()
})
test('buildChildEnv isolates experimental beta kill switch for session-scoped providers', async () => {
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Betas Managed',
apiKey: 'provider-key',
baseUrl: 'https://api.betas.example',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const defaultEnv = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(defaultEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
await providerService.updateProvider(provider.id, { disableExperimentalBetas: true })
const disabledEnv = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(disabledEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
})
test('buildChildEnv injects trace provider metadata for desktop sdk session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Traceable Provider',
apiKey: 'provider-key',
baseUrl: 'https://traceable.example',
apiFormat: 'anthropic',
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ providerId: provider.id },
)) as Record<string, string>
expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1')
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBe(provider.id)
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBe('Traceable Provider')
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBe('anthropic')
})
test('buildChildEnv does not inject trace env when managed trace capture is disabled', async () => {
await updateTraceCaptureSettings({ enabled: false })
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Trace disabled provider',
apiKey: 'provider-key',
baseUrl: 'https://traceable.example',
apiFormat: 'anthropic',
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ providerId: provider.id },
)) as Record<string, string>
expect(env.CC_HAHA_TRACE_API_CALLS).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined()
expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined()
})
test('buildChildEnv uses the session-selected model for session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Switchable',
apiKey: 'provider-key',
baseUrl: 'https://api.switchable.example',
apiFormat: 'openai_chat',
models: {
main: 'old-provider-main',
haiku: 'new-provider-haiku',
sonnet: 'new-provider-sonnet',
opus: 'new-provider-opus',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'new-provider-sonnet',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
})
test('buildChildEnv clears stale api key for bearer-token providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'shengsuanyun',
name: 'ShengSuanYun',
apiKey: 'provider-key',
baseUrl: 'https://router.shengsuanyun.com/api',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'claude-sonnet-4-6',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://router.shengsuanyun.com/api')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('provider-key')
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1')
})
test('buildChildEnv lets General network timeout override provider preset timeouts', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 180_000,
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'shengsuanyun',
name: 'Shengsuanyun',
apiKey: 'provider-key',
baseUrl: 'https://router.shengsuanyun.com/api',
apiFormat: 'anthropic',
models: {
main: 'anthropic/claude-sonnet-4.6',
haiku: 'anthropic/claude-haiku-4.5:thinking',
sonnet: 'anthropic/claude-sonnet-4.6',
opus: 'anthropic/claude-opus-4.7',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'anthropic/claude-sonnet-4.6',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://router.shengsuanyun.com/api')
expect(env.API_TIMEOUT_MS).toBe('180000')
expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
})
test('buildChildEnv can force official auth even when a custom default provider exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'forced-official-token',
refreshToken: 'forced-official-refresh',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: null,
})) as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token')
})
test('buildChildEnv does not inject Claude OAuth when ChatGPT Official is active', async () => {
const providerService = new ProviderService()
await providerService.activateProvider('openai-official')
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'claude-oauth-token-that-must-not-be-used',
refreshToken: 'claude-refresh-token',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
})
test('buildChildEnv injects ChatGPT Official runtime env for session-scoped provider selection', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: 'openai-official',
})) as Record<string, string>
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
)
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.6-terra')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
})
test('buildChildEnv injects isolated Grok Official runtime env for session-scoped selection', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: 'grok-official',
model: 'grok-4.5',
})) as Record<string, string>
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
expect(env.GROK_OAUTH_FILE).toBe(path.join(tmpDir, 'cc-haha', 'grok-oauth.json'))
expect(env.ANTHROPIC_MODEL).toBe('grok-4.5')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
})
test('buildChildEnv passes OpenAI-native effort without leaking Claude effort state', async () => {
const originalEffort = process.env.CC_HAHA_OPENAI_REASONING_EFFORT
process.env.CC_HAHA_OPENAI_REASONING_EFFORT = 'stale-parent-effort'
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: 'openai-official',
model: 'gpt-5.6-sol',
effort: 'xhigh',
})) as Record<string, string>
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol')
expect(env.CC_HAHA_OPENAI_REASONING_EFFORT).toBe('xhigh')
} finally {
if (originalEffort === undefined) delete process.env.CC_HAHA_OPENAI_REASONING_EFFORT
else process.env.CC_HAHA_OPENAI_REASONING_EFFORT = originalEffort
}
})
test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: {} }),
'utf-8',
)
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
})
test('buildChildEnv injects desktop Computer Use host bundle id for sdk sessions', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID).toBe(
'com.claude-code-haha.desktop',
)
expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1')
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1')
})
test('uses bun entrypoint fallback on Windows dev mode', () => {
const service = new ConversationService() as any
const args = service.resolveCliArgs(['--print'])
if (process.platform === 'win32') {
expect(args[0]).toBe(process.execPath)
expect(args[1]).toBe('--preload')
expect(args[2]).toContain('preload.ts')
expect(args[3]).toContain(path.join('src', 'entrypoints', 'cli.tsx'))
} else {
expect(args[0]).toContain(path.join('bin', 'claude-haha'))
}
})
test('buildSessionCliArgs enables partial assistant messages for desktop streaming', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
{ permissionMode: 'bypassPermissions' },
) as string[]
expect(args).toContain('--include-partial-messages')
expect(args).toContain('--sdk-url')
expect(args).toContain('--replay-user-messages')
})
test('buildChildEnv asks desktop SDK sessions to wait briefly for MCP tools', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP).toBe('1')
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000')
})
test('buildChildEnv disables inherited interrupted-turn resume for prewarm launches', async () => {
process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = '1'
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ resumeInterruptedTurn: false },
)) as Record<string, string>
expect(env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN).toBeUndefined()
})
test('buildChildEnv enables stream idle watchdog for desktop CLI sessions', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('1')
})
test('buildChildEnv widens the stream idle window and disables the non-streaming fallback (#766)', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
// 90s default kills healthy-but-silent third-party streams; 240s keeps the
// watchdog useful without aborting slow thinking/prefill phases.
expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('240000')
// Non-streaming fallback can never finish for slow providers (first byte
// only arrives after FULL generation), so retries must stay streaming.
expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('1')
})
test('buildChildEnv respects caller overrides for stream timeout tuning envs', async () => {
const service = new ConversationService() as any
const previous = {
watchdog: process.env.CLAUDE_ENABLE_STREAM_WATCHDOG,
idle: process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS,
fallback: process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK,
}
process.env.CLAUDE_ENABLE_STREAM_WATCHDOG = '0'
process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = '90000'
process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK = '0'
try {
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('0')
expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('90000')
expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('0')
} finally {
for (const [key, value] of [
['CLAUDE_ENABLE_STREAM_WATCHDOG', previous.watchdog],
['CLAUDE_STREAM_IDLE_TIMEOUT_MS', previous.idle],
['CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK', previous.fallback],
] as const) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
{
model: 'model-b-opus',
effort: 'max',
},
) as string[]
expect(args).toContain('--model')
expect(args).toContain('model-b-opus')
expect(args).toContain('--effort')
expect(args).toContain('max')
})
test('buildSessionCliArgs starts pending desktop worktrees through the native CLI flag', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
undefined,
{
requestedWorkDir: '/tmp/source-repo',
repoRoot: '/tmp/source-repo',
branch: 'feature/rail',
worktree: true,
baseRef: 'feature/rail',
worktreeSlug: 'desktop-feature-rail-123e4567',
},
) as string[]
expect(args).toContain('--worktree')
expect(args).toContain('desktop-feature-rail-123e4567')
expect(args).toContain('--worktree-base-ref')
expect(args).toContain('feature/rail')
})
test('stopAllSessionsAndWait kills every active CLI subprocess and waits for exits', async () => {
const service = new ConversationService() as any
const killed: string[] = []
const drained: string[] = []
const makeSession = (sessionId: string) => {
let resolveExit: (code: number) => void = () => {}
const exited = new Promise<number>((resolve) => {
resolveExit = resolve
})
return {
proc: {
kill: () => {
killed.push(sessionId)
resolveExit(0)
},
exited,
},
outputCallbacks: [],
workDir: tmpDir,
permissionMode: 'default',
sdkToken: `${sessionId}-token`,
sdkSocket: null,
pendingOutbound: [],
startupPending: false,
startupExitCode: null,
stdoutLines: [],
stderrLines: [],
outputDrain: Promise.resolve().then(() => {
drained.push(sessionId)
}),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
}
}
service.sessions.set('session-a', makeSession('session-a'))
service.sessions.set('session-b', makeSession('session-b'))
await service.stopAllSessionsAndWait(500)
expect(killed.sort()).toEqual(['session-a', 'session-b'])
expect(drained.sort()).toEqual(['session-a', 'session-b'])
expect(service.getActiveSessions()).toEqual([])
})
test('default CLI shutdown wait covers the CLI graceful cleanup budget', () => {
expect(DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS).toBeGreaterThanOrEqual(6_000)
})
test('isolates SDK output callbacks so one broken client cannot swallow turn completion', () => {
const service = new ConversationService() as any
let completionObserved = false
service.sessions.set('callback-isolation', {
outputCallbacks: [
() => { throw new Error('closed client socket') },
(message: any) => { completionObserved = message.type === 'result' },
],
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
service.handleSdkPayload('callback-isolation', JSON.stringify({
type: 'result',
subtype: 'success',
is_error: false,
}))
expect(completionObserved).toBe(true)
})
// CLI 的 WebSocketTransport 每次重连成功都会把整个发送缓冲区重放一遍,并假定
// 「The server deduplicates by UUID」。以前 server 没实现这个契约:笔记本睡醒后
// CLI 重连,一整轮早已结束的对话会被重新推上来,前端当成实时输出再渲染一遍
// (表现为满屏「已思考」)。
test('drops SDK messages replayed by the CLI after a reconnect', () => {
const service = new ConversationService() as any
const forwarded: any[] = []
service.sessions.set('replay-dedupe', {
outputCallbacks: [(message: any) => forwarded.push(message)],
seenSdkMessageUuids: new Set<string>(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
const turn = [
{ type: 'assistant', uuid: 'uuid-thinking-1', message: { content: [{ type: 'thinking', thinking: 'step one' }] } },
{ type: 'assistant', uuid: 'uuid-thinking-2', message: { content: [{ type: 'thinking', thinking: 'step two' }] } },
{ type: 'result', uuid: 'uuid-result', subtype: 'success', is_error: false },
]
const payload = turn.map((msg) => JSON.stringify(msg)).join('\n')
service.handleSdkPayload('replay-dedupe', payload)
expect(forwarded).toHaveLength(3)
// 重连后 CLI 把同一批消息从缓冲区头部重放 —— 一条都不该再转发出去。
service.handleSdkPayload('replay-dedupe', payload)
service.handleSdkPayload('replay-dedupe', payload)
expect(forwarded).toHaveLength(3)
})
// 真机日志cli-diagnostics.jsonl.58975)显示重放的 858 条里绝大多数是 stream_event
// 桌面端固定传 --include-partial-messagesCLI 为每个 thinking_delta 单独产一条
// stream_event 并现铸 uuidQueryEngine.ts:846所以重放到达前端时是 delta 碎片,
// 而不是整块 thinking。渲染侧的逐字比对挡不住碎片只有这里的 uuid 判重挡得住。
test('drops replayed partial-message stream events, not just whole assistant blocks', () => {
const service = new ConversationService() as any
const forwarded: any[] = []
service.sessions.set('replay-stream-events', {
outputCallbacks: [(message: any) => forwarded.push(message)],
seenSdkMessageUuids: new Set<string>(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
const deltas = ['Start: get_', 'app_state to find ', 'the search box.']
const payload = deltas
.map((thinking, index) =>
JSON.stringify({
type: 'stream_event',
uuid: `uuid-stream-${index}`,
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking },
},
}),
)
.join('\n')
service.handleSdkPayload('replay-stream-events', payload)
expect(forwarded).toHaveLength(deltas.length)
service.handleSdkPayload('replay-stream-events', payload)
expect(forwarded).toHaveLength(deltas.length)
})
test('keeps forwarding SDK messages that carry no uuid', () => {
const service = new ConversationService() as any
const forwarded: any[] = []
service.sessions.set('no-uuid', {
outputCallbacks: [(message: any) => forwarded.push(message)],
seenSdkMessageUuids: new Set<string>(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
// 没有 uuid 的消息不会进 CLI 的重放缓冲,所以也不该被判重丢弃。
const payload = JSON.stringify({ type: 'result', subtype: 'success', is_error: false })
service.handleSdkPayload('no-uuid', payload)
service.handleSdkPayload('no-uuid', payload)
expect(forwarded).toHaveLength(2)
})
test('tolerates sessions created without the replay-dedupe bookkeeping', () => {
const service = new ConversationService() as any
const forwarded: any[] = []
// 故意不带 seenSdkMessageUuids模拟别处构造出来的会话对象。
service.sessions.set('legacy-shape', {
outputCallbacks: [(message: any) => forwarded.push(message)],
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
const payload = JSON.stringify({
type: 'assistant',
uuid: 'uuid-legacy',
message: { content: [{ type: 'thinking', thinking: 'hello' }] },
})
service.handleSdkPayload('legacy-shape', payload)
service.handleSdkPayload('legacy-shape', payload)
expect(forwarded).toHaveLength(1)
})
test('remembers enough uuids to cover a full CLI replay buffer', () => {
const service = new ConversationService() as any
const forwarded: any[] = []
service.sessions.set('buffer-span', {
outputCallbacks: [(message: any) => forwarded.push(message)],
seenSdkMessageUuids: new Set<string>(),
sdkMessages: [],
initMessage: null,
pendingPermissionRequests: new Map(),
})
// CLI 侧缓冲上限是 1000 条,整个缓冲区被重放时每一条都必须还认得出来。
const CLI_REPLAY_BUFFER_SIZE = 1000
const payload = Array.from({ length: CLI_REPLAY_BUFFER_SIZE }, (_unused, index) =>
JSON.stringify({ type: 'assistant', uuid: `uuid-${index}`, message: { content: [] } }),
).join('\n')
service.handleSdkPayload('buffer-span', payload)
expect(forwarded).toHaveLength(CLI_REPLAY_BUFFER_SIZE)
service.handleSdkPayload('buffer-span', payload)
expect(forwarded).toHaveLength(CLI_REPLAY_BUFFER_SIZE)
})
test('removes an exited CLI session even when one output callback throws', async () => {
const service = new ConversationService() as any
const sessionId = 'exit-callback-isolation'
const proc = {
exited: Promise.resolve(1),
kill: () => {},
}
let completionObserved = false
service.sessions.set(sessionId, {
proc,
startupPending: false,
startupExitCode: null,
outputDrain: Promise.resolve(),
outputCallbacks: [
() => { throw new Error('closed client socket') },
(message: any) => { completionObserved = message.type === 'result' },
],
workDir: tmpDir,
permissionMode: 'default',
stdoutLines: [],
stderrLines: [],
sdkMessages: [],
pendingPermissionRequests: new Map(),
})
await service.handleProcessExit(sessionId, proc, 1)
expect(completionObserved).toBe(true)
expect(service.hasSession(sessionId)).toBe(false)
})
test('summarizes SDK diagnostics with transport metadata only', () => {
const service = new ConversationService()
const summarized = (service as any).summarizeSdkMessages([{
type: 'assistant',
subtype: 'api_error',
is_error: true,
status: 'failed',
result: 'PRIVATE_SDK_RESULT',
error: 'PRIVATE_SDK_ERROR',
errorDetails: 'PRIVATE_ERROR_DETAILS',
message: {
content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }],
},
}])
expect(summarized).toEqual([{
type: 'assistant',
subtype: 'api_error',
is_error: true,
status: 'failed',
errorCategory: 'api_error',
}])
const serialized = JSON.stringify(summarized)
expect(serialized).not.toContain('PRIVATE_SDK_RESULT')
expect(serialized).not.toContain('PRIVATE_SDK_ERROR')
expect(serialized).not.toContain('PRIVATE_ERROR_DETAILS')
expect(serialized).not.toContain('PRIVATE_ASSISTANT_REPLY')
})
})
function sanitizeMemoryPath(value: string): string {
return value.replace(/[^a-zA-Z0-9]/g, '-')
}