mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: resolve first issue 809 batch
Fixes local Batch A from #809+ triage: configurable scheduled task timeout (#809/#846), OpenAI proxy trace request headers (#836), title generation auth strategy (#854), and unresolved parallel tool card settlement (#889). Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts Tested: bun test src/server/__tests__/title-service.test.ts Tested: bun test src/server/__tests__/proxy-network-settings.test.ts Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts Tested: bun run check:server Tested: bun run check:desktop Not-tested: bun run verify and coverage were not run because this is a local batch checkpoint, not a PR-ready/push-ready handoff. Confidence: high Scope-risk: moderate
This commit is contained in:
parent
e944db1e2f
commit
2f8c2a9a70
@ -186,6 +186,78 @@ describe('stripGeneratedImageMetadataLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatStore tool settlement', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
updateTabStatusMock.mockReset()
|
||||
notifyDesktopMock.mockReset()
|
||||
localStorage.clear()
|
||||
useChatStore.setState({
|
||||
...initialState,
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('marks sibling pending tool calls stopped when a parallel tool result fails and the turn completes', () => {
|
||||
const store = useChatStore.getState()
|
||||
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'content_start',
|
||||
blockType: 'tool_use',
|
||||
toolName: 'Grep',
|
||||
toolUseId: 'grep-1',
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Grep',
|
||||
toolUseId: 'grep-1',
|
||||
input: { pattern: 'needle' },
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'content_start',
|
||||
blockType: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: '/missing.md' },
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'read-1',
|
||||
content: 'File does not exist',
|
||||
isError: true,
|
||||
})
|
||||
store.handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session).toBeTruthy()
|
||||
if (!session) return
|
||||
const grep = session.messages.find((message) =>
|
||||
message.type === 'tool_use' && message.toolUseId === 'grep-1',
|
||||
)
|
||||
const read = session.messages.find((message) =>
|
||||
message.type === 'tool_use' && message.toolUseId === 'read-1',
|
||||
)
|
||||
|
||||
expect(read).toMatchObject({ type: 'tool_use', isPending: false })
|
||||
expect(grep).toMatchObject({
|
||||
type: 'tool_use',
|
||||
isPending: false,
|
||||
status: 'stopped',
|
||||
})
|
||||
expect(session.chatState).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatStore history mapping', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
|
||||
@ -357,9 +357,19 @@ function upsertToolUseMessage(
|
||||
}
|
||||
|
||||
function markPendingToolUseMessagesStopped(messages: UIMessage[]): UIMessage[] {
|
||||
const resolvedToolUseIds = new Set(
|
||||
messages
|
||||
.filter((message) => message.type === 'tool_result')
|
||||
.map((message) => message.toolUseId),
|
||||
)
|
||||
let changed = false
|
||||
const stoppedMessages = messages.map((message) => {
|
||||
if (message.type !== 'tool_use' || !message.isPending) return message
|
||||
if (
|
||||
message.type !== 'tool_use' ||
|
||||
(!message.isPending && resolvedToolUseIds.has(message.toolUseId))
|
||||
) {
|
||||
return message
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...message,
|
||||
@ -1893,8 +1903,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
} else if (text !== session.streamingText) {
|
||||
update(() => ({ streamingText: text }))
|
||||
}
|
||||
const appendedCompletionMessage = completionMessages !== session.messages
|
||||
const finalMessages = markPendingToolUseMessagesStopped(completionMessages)
|
||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
update(() => ({
|
||||
messages: finalMessages,
|
||||
tokenUsage: msg.usage,
|
||||
chatState: 'idle',
|
||||
activeThinkingId: null,
|
||||
@ -1905,9 +1918,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
streamingFallback: null,
|
||||
}))
|
||||
useTabStore.getState().updateTabStatus(sessionId, 'idle')
|
||||
const appendedCompletionMessage = completionMessages !== session.messages
|
||||
const notification = wasAgentRunning && appendedCompletionMessage
|
||||
? buildAgentCompletionNotification(sessionId, completionMessages, text)
|
||||
? buildAgentCompletionNotification(sessionId, finalMessages, text)
|
||||
: null
|
||||
if (notification) {
|
||||
void notifyDesktop({
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
buildCronCliArgs,
|
||||
buildCronTaskSpawnOptions,
|
||||
CronScheduler,
|
||||
resolveCronTaskTimeoutMs,
|
||||
resolveCronProjectRoot,
|
||||
} from '../services/cronScheduler.js'
|
||||
import { CronService } from '../services/cronService.js'
|
||||
@ -24,6 +25,7 @@ const originalHome = process.env.HOME
|
||||
const originalShell = process.env.SHELL
|
||||
const originalZdotdir = process.env.ZDOTDIR
|
||||
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
||||
const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
const unixOnly = isWindows ? it.skip : it
|
||||
@ -107,6 +109,11 @@ function restoreEnv(): void {
|
||||
} else {
|
||||
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
||||
}
|
||||
if (originalTaskTimeout) {
|
||||
process.env.CC_HAHA_TASK_TIMEOUT_MS = originalTaskTimeout
|
||||
} else {
|
||||
delete process.env.CC_HAHA_TASK_TIMEOUT_MS
|
||||
}
|
||||
resetTerminalShellEnvironmentCacheForTests()
|
||||
}
|
||||
|
||||
@ -125,6 +132,64 @@ describe('cron scheduler launcher resolution', () => {
|
||||
await cleanupTmpDir(tmpDir)
|
||||
})
|
||||
|
||||
it('uses a configurable scheduled task timeout with the default unchanged', () => {
|
||||
expect(resolveCronTaskTimeoutMs({})).toBe(10 * 60 * 1000)
|
||||
expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: '1800000' })).toBe(1_800_000)
|
||||
expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: 'not-a-number' })).toBe(10 * 60 * 1000)
|
||||
expect(resolveCronTaskTimeoutMs({ CC_HAHA_TASK_TIMEOUT_MS: '0' })).toBe(10 * 60 * 1000)
|
||||
})
|
||||
|
||||
unixOnly('executeTask arms the subprocess timeout from CC_HAHA_TASK_TIMEOUT_MS', async () => {
|
||||
const binDir = path.join(tmpDir, 'bin')
|
||||
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
|
||||
const appRoot = path.join(tmpDir, 'app-root')
|
||||
await fs.mkdir(binDir, { recursive: true })
|
||||
await fs.mkdir(appRoot, { recursive: true })
|
||||
await fs.writeFile(
|
||||
sidecarPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'/bin/cat >/dev/null',
|
||||
'printf \'%s\\n\' \'{"type":"result","result":"timeout env ok"}\'',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.chmod(sidecarPath, 0o755)
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const timeoutCalls: number[] = []
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
timeoutCalls.push(timeout ?? 0)
|
||||
return originalSetTimeout(handler, timeout, ...args)
|
||||
}) as typeof setTimeout
|
||||
|
||||
process.env.PATH = binDir
|
||||
process.env.CLAUDE_CLI_PATH = sidecarPath
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CC_HAHA_TASK_TIMEOUT_MS = '12345'
|
||||
|
||||
try {
|
||||
const cronService = new CronService()
|
||||
const scheduler = new CronScheduler(cronService)
|
||||
const task = await cronService.createTask({
|
||||
cron: '* * * * *',
|
||||
prompt: 'cron timeout env test',
|
||||
name: 'Timeout Env Task',
|
||||
recurring: true,
|
||||
folderPath: tmpDir,
|
||||
})
|
||||
|
||||
const run = await scheduler.executeTask(task)
|
||||
|
||||
expect(run.status).toBe('completed')
|
||||
expect(timeoutCalls).toContain(12_345)
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the bundled sidecar launcher when one is configured', () => {
|
||||
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
|
||||
const appRoot = path.join(tmpDir, 'app-root')
|
||||
|
||||
@ -4,6 +4,10 @@ import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { handleProxyRequest, withStreamIdleTimeout } from '../proxy/handler.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import {
|
||||
clearTraceCaptureStateForTests,
|
||||
traceCaptureService,
|
||||
} from '../services/traceCaptureService.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
|
||||
let tmpDir: string
|
||||
@ -14,6 +18,7 @@ async function setup() {
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
resetSettingsCache()
|
||||
clearTraceCaptureStateForTests()
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
@ -23,9 +28,19 @@ async function teardown() {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
resetSettingsCache()
|
||||
clearTraceCaptureStateForTests()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function waitForTraceCall(sessionId: string) {
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
const trace = await traceCaptureService.getSessionTrace(sessionId)
|
||||
if (trace.calls.length > 0 && trace.calls[0]?.response) return trace
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
return traceCaptureService.getSessionTrace(sessionId)
|
||||
}
|
||||
|
||||
describe('proxy network settings', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
@ -180,6 +195,70 @@ describe('proxy network settings', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('records redacted OpenAI proxy request headers in trace capture', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.addProvider({
|
||||
presetId: 'custom',
|
||||
name: 'OpenAI Trace Proxy',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'sk-trace-secret',
|
||||
apiFormat: 'openai_chat',
|
||||
models: {
|
||||
main: 'model-main',
|
||||
haiku: 'model-main',
|
||||
sonnet: 'model-main',
|
||||
opus: 'model-main',
|
||||
},
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => {
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl-trace-headers',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'model-main',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const req = new Request(
|
||||
`http://localhost:3456/proxy/providers/${provider.id}/v1/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-claude-code-session-id': 'session-proxy-trace-headers',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'model-main',
|
||||
max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const res = await handleProxyRequest(req, new URL(req.url))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const trace = await waitForTraceCall('session-proxy-trace-headers')
|
||||
expect(trace.calls).toHaveLength(1)
|
||||
expect(trace.calls[0].request.headers.Authorization).toBe('[redacted]')
|
||||
expect(trace.calls[0].request.headers['Content-Type']).toBe('application/json')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('uses configured AI request timeout while opening and reading streaming upstream requests', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
|
||||
@ -367,6 +367,58 @@ describe('titleService', () => {
|
||||
expect(upstreamCalls[0].body.stream).toBe(true)
|
||||
})
|
||||
|
||||
test('honors the configured provider auth strategy for title generation', async () => {
|
||||
const upstreamCalls: Array<{ headers: Record<string, string> }> = []
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
upstreamCalls.push({
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
})
|
||||
return Response.json({
|
||||
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const providerId = 'auth-token-title-test'
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({
|
||||
activeId: providerId,
|
||||
providers: [
|
||||
{
|
||||
id: providerId,
|
||||
presetId: 'custom',
|
||||
name: 'Bearer Provider',
|
||||
apiKey: 'bearer-title-key',
|
||||
authStrategy: 'auth_token',
|
||||
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'bearer-main',
|
||||
haiku: 'bearer-haiku',
|
||||
sonnet: 'bearer-main',
|
||||
opus: 'bearer-main',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, null, 2),
|
||||
)
|
||||
|
||||
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
|
||||
|
||||
expect(upstreamCalls).toHaveLength(1)
|
||||
expect(upstreamCalls[0].headers.authorization).toBe('Bearer bearer-title-key')
|
||||
expect(upstreamCalls[0].headers['x-api-key']).toBeUndefined()
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('parses JSON title responses wrapped in markdown fences', () => {
|
||||
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
|
||||
.toBe('Write bash script')
|
||||
|
||||
@ -271,6 +271,10 @@ async function handleOpenaiChat(
|
||||
imageContentMode: shouldUseTextOnlyOpenAIChatContent(baseUrl) ? 'text_only' : 'vision',
|
||||
})
|
||||
const url = `${baseUrl}/v1/chat/completions`
|
||||
const upstreamRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}
|
||||
const proxyOptions = getProxyFetchOptions({ proxyUrl })
|
||||
const startedAtMs = Date.now()
|
||||
const startedAt = new Date(startedAtMs).toISOString()
|
||||
@ -280,6 +284,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
})
|
||||
: undefined
|
||||
@ -288,10 +293,7 @@ async function handleOpenaiChat(
|
||||
try {
|
||||
upstream = await fetchUpstreamWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
headers: upstreamRequestHeaders,
|
||||
body: JSON.stringify(transformed),
|
||||
...proxyOptions,
|
||||
}, aiRequestTimeoutMs, isStream)
|
||||
@ -303,6 +305,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
error: err,
|
||||
@ -328,6 +331,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: upstream.status,
|
||||
@ -351,6 +355,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
error: new Error('Upstream returned no body for stream'),
|
||||
@ -371,6 +376,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: 200,
|
||||
@ -400,6 +406,7 @@ async function handleOpenaiChat(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: 200,
|
||||
@ -434,6 +441,10 @@ async function handleOpenaiResponses(
|
||||
): Promise<Response> {
|
||||
const transformed = anthropicToOpenaiResponses(body, { cacheKey: promptCacheKey })
|
||||
const url = `${baseUrl}/v1/responses`
|
||||
const upstreamRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}
|
||||
const proxyOptions = getProxyFetchOptions({ proxyUrl })
|
||||
const startedAtMs = Date.now()
|
||||
const startedAt = new Date(startedAtMs).toISOString()
|
||||
@ -443,6 +454,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
})
|
||||
: undefined
|
||||
@ -451,10 +463,7 @@ async function handleOpenaiResponses(
|
||||
try {
|
||||
upstream = await fetchUpstreamWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
headers: upstreamRequestHeaders,
|
||||
body: JSON.stringify(transformed),
|
||||
...proxyOptions,
|
||||
}, aiRequestTimeoutMs, isStream)
|
||||
@ -466,6 +475,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
error: err,
|
||||
@ -491,6 +501,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: upstream.status,
|
||||
@ -514,6 +525,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
error: new Error('Upstream returned no body for stream'),
|
||||
@ -534,6 +546,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: 200,
|
||||
@ -563,6 +576,7 @@ async function handleOpenaiResponses(
|
||||
model: body.model,
|
||||
upstreamUrl: url,
|
||||
upstreamRequest: transformed,
|
||||
requestHeaders: upstreamRequestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus: 200,
|
||||
@ -608,12 +622,14 @@ function startProxyTraceCall({
|
||||
model,
|
||||
upstreamUrl,
|
||||
upstreamRequest,
|
||||
requestHeaders,
|
||||
startedAt,
|
||||
}: {
|
||||
context: ProxyTraceContext
|
||||
model: string
|
||||
upstreamUrl: string
|
||||
upstreamRequest: unknown
|
||||
requestHeaders: Record<string, string>
|
||||
startedAt: string
|
||||
}): string {
|
||||
const callId = createTraceCallId()
|
||||
@ -628,6 +644,7 @@ function startProxyTraceCall({
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: upstreamUrl,
|
||||
headers: requestHeaders,
|
||||
bodySnapshot: createTraceBodySnapshot({
|
||||
pending: true,
|
||||
note: 'proxy request body captured on call completion',
|
||||
@ -660,6 +677,7 @@ async function recordProxyTrace({
|
||||
model,
|
||||
upstreamUrl,
|
||||
upstreamRequest,
|
||||
requestHeaders,
|
||||
startedAt,
|
||||
startedAtMs,
|
||||
responseStatus,
|
||||
@ -674,6 +692,7 @@ async function recordProxyTrace({
|
||||
model: string
|
||||
upstreamUrl: string
|
||||
upstreamRequest: unknown
|
||||
requestHeaders?: Record<string, string>
|
||||
startedAt: string
|
||||
startedAtMs: number
|
||||
responseStatus?: number
|
||||
@ -704,6 +723,7 @@ async function recordProxyTrace({
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: upstreamUrl,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
},
|
||||
...(responseStatus !== undefined
|
||||
|
||||
@ -262,7 +262,19 @@ function trimRuns(data: RunsFile): void {
|
||||
|
||||
// ─── Scheduler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
export function resolveCronTaskTimeoutMs(
|
||||
env: { CC_HAHA_TASK_TIMEOUT_MS?: string } = process.env,
|
||||
): number {
|
||||
const raw = env.CC_HAHA_TASK_TIMEOUT_MS?.trim()
|
||||
if (!raw) return DEFAULT_TASK_TIMEOUT_MS
|
||||
|
||||
const timeoutMs = Number(raw)
|
||||
return Number.isInteger(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: DEFAULT_TASK_TIMEOUT_MS
|
||||
}
|
||||
|
||||
type CronCliResolutionOptions = {
|
||||
cliPath?: string | null
|
||||
@ -530,6 +542,7 @@ export class CronScheduler {
|
||||
])
|
||||
|
||||
const childEnv = await this.buildTaskChildEnv(workDir, task)
|
||||
const taskTimeoutMs = resolveCronTaskTimeoutMs()
|
||||
const proc = Bun.spawn(
|
||||
cliArgs,
|
||||
buildCronTaskSpawnOptions(workDir, childEnv),
|
||||
@ -554,7 +567,7 @@ export class CronScheduler {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, TASK_TIMEOUT_MS)
|
||||
}, taskTimeoutMs)
|
||||
|
||||
try {
|
||||
// Collect stdout
|
||||
@ -585,7 +598,7 @@ export class CronScheduler {
|
||||
new Date(completedAt).getTime() - new Date(startedAt).getTime()
|
||||
|
||||
// Determine if this was a timeout
|
||||
const wasTimeout = durationMs >= TASK_TIMEOUT_MS
|
||||
const wasTimeout = durationMs >= taskTimeoutMs
|
||||
|
||||
// Extract only meaningful AI text responses from raw NDJSON output.
|
||||
// The raw stream contains system/init messages, tool_use blocks, and
|
||||
@ -817,13 +830,14 @@ export class CronScheduler {
|
||||
const data = await readRunsFile()
|
||||
let changed = false
|
||||
const now = Date.now()
|
||||
const taskTimeoutMs = resolveCronTaskTimeoutMs()
|
||||
|
||||
for (const run of data.runs) {
|
||||
if (run.status !== 'running') continue
|
||||
const startedAt = new Date(run.startedAt).getTime()
|
||||
// If "running" for longer than the task timeout + 1-minute buffer,
|
||||
// the owning process is certainly dead.
|
||||
if (now - startedAt > TASK_TIMEOUT_MS + 60_000) {
|
||||
if (now - startedAt > taskTimeoutMs + 60_000) {
|
||||
run.status = 'failed'
|
||||
run.error = 'Process terminated before task could complete'
|
||||
run.completedAt = new Date().toISOString()
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { ProviderService } from './providerService.js'
|
||||
import { getPresetAuthStrategy } from './providerRuntimeEnv.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js'
|
||||
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
|
||||
@ -16,6 +17,7 @@ import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenai
|
||||
import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js'
|
||||
import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js'
|
||||
import { extractConversationText, SESSION_TITLE_PROMPT } from '../../utils/sessionTitle.js'
|
||||
import type { ProviderAuthStrategy } from '../types/provider.js'
|
||||
|
||||
const TITLE_MAX_LEN = 50
|
||||
const TITLE_MAX_OUTPUT_TOKENS = 100
|
||||
@ -102,6 +104,36 @@ function buildTitleUserPrompt(
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildAnthropicTitleRequestHeaders(
|
||||
apiKey: string,
|
||||
authStrategy: ProviderAuthStrategy,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
|
||||
switch (authStrategy) {
|
||||
case 'api_key':
|
||||
headers['x-api-key'] = apiKey
|
||||
break
|
||||
case 'auth_token':
|
||||
case 'auth_token_empty_api_key':
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
break
|
||||
case 'dual_same_token':
|
||||
headers['x-api-key'] = apiKey
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
break
|
||||
case 'dual_dummy':
|
||||
headers['x-api-key'] = 'dummy'
|
||||
headers.Authorization = 'Bearer dummy'
|
||||
break
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick placeholder title derived from user message text.
|
||||
* Returns first sentence, collapsed to single line, max 50 chars.
|
||||
@ -157,11 +189,8 @@ export async function generateTitle(
|
||||
|
||||
const model = resolvedProvider.models.haiku || resolvedProvider.models.main
|
||||
const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages`
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': resolvedProvider.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
const authStrategy = resolvedProvider.authStrategy ?? getPresetAuthStrategy(resolvedProvider.presetId)
|
||||
const requestHeaders = buildAnthropicTitleRequestHeaders(resolvedProvider.apiKey, authStrategy)
|
||||
const requestBody = {
|
||||
model,
|
||||
max_tokens: TITLE_MAX_OUTPUT_TOKENS,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user