mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
Merge remote-tracking branch 'origin/main' into fix/pr-1301-maintainer
This commit is contained in:
@@ -126,8 +126,16 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
- name: Install ripgrep
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run root runtime checks
|
||||
run: bun run check:server
|
||||
|
||||
@@ -287,6 +295,8 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
- name: Install ripgrep
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
|
||||
+1
-4
@@ -1,10 +1,7 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/images/logo-horizontal-dark.png">
|
||||
<img src="docs/images/logo-horizontal.png" alt="Claude Code Haha" width="480">
|
||||
</picture>
|
||||
<img src="docs/images/readme-cover-en.jpg" alt="cc-haha — Open-source desktop for Claude Code" width="960">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/images/logo-horizontal-dark.png">
|
||||
<img src="docs/images/logo-horizontal.png" alt="Claude Code Haha" width="480">
|
||||
</picture>
|
||||
<img src="docs/images/readme-cover-zh.jpg" alt="cc-haha — Claude Code 开源桌面端" width="960">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('ModelSelector', () => {
|
||||
|
||||
await clickByRole(/Opus 4\.7/i)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fable 5/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Fable 5\.1/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Opus 4\.8/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Sonnet 5/ })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /Opus 4\.7/ }).length).toBeGreaterThan(0)
|
||||
|
||||
@@ -3,6 +3,14 @@ import type { ModelInfo } from '../types/settings'
|
||||
export const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-8'
|
||||
|
||||
export const OFFICIAL_MODELS: ModelInfo[] = [
|
||||
{
|
||||
id: 'claude-fable-5-1',
|
||||
name: 'Fable 5.1',
|
||||
description: 'Highest capability for long-running tasks',
|
||||
context: '1m',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Fable 5',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TraceSession } from './TraceSession'
|
||||
@@ -503,16 +503,35 @@ describe('TraceSession', () => {
|
||||
},
|
||||
calls: [baseTrace.calls[0]!, makeCall({ id: 'call-2', startedAt: '2026-06-09T10:00:08.000Z' })],
|
||||
})
|
||||
await renderReady(20)
|
||||
// Advance polls only after each state is asserted. A real 20ms interval can
|
||||
// invalidate the detail cache before the initial assertion under coverage.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
try {
|
||||
await renderReady(20)
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
|
||||
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3))
|
||||
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
|
||||
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
|
||||
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1)
|
||||
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
|
||||
|
||||
await screen.findByText('claude-sonnet-4-5 x2')
|
||||
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
|
||||
const detail = within(screen.getByTestId('trace-detail'))
|
||||
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
|
||||
for (const expectedTraceCalls of [2, 3]) {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
|
||||
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(expectedTraceCalls)
|
||||
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByText('claude-sonnet-4-5 x2')).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
|
||||
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(4)
|
||||
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('claude-sonnet-4-5 x2')).toBeInTheDocument()
|
||||
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
|
||||
const detail = within(screen.getByTestId('trace-detail'))
|
||||
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not refetch full messages for identical trace polls', async () => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -4,7 +4,7 @@ import { parse } from 'yaml'
|
||||
|
||||
type WorkflowJob = {
|
||||
needs?: string | string[]
|
||||
steps?: Array<{ name?: string; run?: string }>
|
||||
steps?: Array<{ name?: string; run?: string; 'working-directory'?: string }>
|
||||
}
|
||||
|
||||
function workflowJobs(workflow: string) {
|
||||
@@ -70,6 +70,28 @@ describe('PR quality workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('installs imported workspace dependencies and ripgrep before runtime and coverage tests', () => {
|
||||
const jobs = workflowJobs(readFileSync('.github/workflows/pr-quality.yml', 'utf8'))
|
||||
for (const [job, command] of [
|
||||
['server-checks', 'bun run check:server'],
|
||||
['coverage-checks', 'bun run check:coverage'],
|
||||
]) {
|
||||
const steps = jobs[job].steps ?? []
|
||||
const check = steps.findIndex(step => step.run === command)
|
||||
expect(check).toBeGreaterThanOrEqual(0)
|
||||
for (const workspace of ['desktop', 'adapters']) {
|
||||
const install = steps.findIndex(step =>
|
||||
step['working-directory'] === workspace && step.run === 'bun install --frozen-lockfile',
|
||||
)
|
||||
expect(install).toBeGreaterThanOrEqual(0)
|
||||
expect(install).toBeLessThan(check)
|
||||
}
|
||||
const ripgrep = steps.findIndex(step => step.run?.includes('apt-get install -y ripgrep'))
|
||||
expect(ripgrep).toBeGreaterThanOrEqual(0)
|
||||
expect(ripgrep).toBeLessThan(check)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps coverage artifacts observable in CI', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { feature } from 'bun:bundle'
|
||||
import {
|
||||
resetSettingsCache,
|
||||
@@ -8,14 +8,23 @@ import {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key'
|
||||
|
||||
let capturedQuery: Record<string, unknown> | undefined
|
||||
mock.module('../../utils/sideQuery.js', () => ({
|
||||
sideQuery: async (options: Record<string, unknown>) => {
|
||||
const sideQueryModule = await import('../../utils/sideQuery.js')
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(sideQueryModule, 'sideQuery').mockImplementation(async options => {
|
||||
capturedQuery = options
|
||||
return {
|
||||
id: 'msg_critique',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'test-model',
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
content: [{ type: 'text', text: 'critique complete' }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
} as Awaited<ReturnType<typeof sideQueryModule.sideQuery>>
|
||||
})
|
||||
})
|
||||
|
||||
const transcriptClassifierEnabled = feature('TRANSCRIPT_CLASSIFIER')
|
||||
? true
|
||||
|
||||
@@ -3,6 +3,7 @@ import { feature } from 'bun:bundle'
|
||||
export const CLAUDE_CODE_20250219_BETA_HEADER = 'claude-code-20250219'
|
||||
export const INTERLEAVED_THINKING_BETA_HEADER =
|
||||
'interleaved-thinking-2025-05-14'
|
||||
export const THINKING_BINDING_CONTROLS_BETA_HEADER = 'thinking-binding-controls-2026-08-01'
|
||||
export const CONTEXT_1M_BETA_HEADER = 'context-1m-2025-08-07'
|
||||
export const CONTEXT_MANAGEMENT_BETA_HEADER = 'context-management-2025-06-27'
|
||||
export const STRUCTURED_OUTPUTS_BETA_HEADER = 'structured-outputs-2025-12-15'
|
||||
|
||||
@@ -374,8 +374,9 @@ describe('Business Flow: Models & Effort', () => {
|
||||
|
||||
it('should return available fallback models', async () => {
|
||||
const { data } = await api('GET', '/api/models')
|
||||
expect(data.models.length).toBe(4)
|
||||
expect(data.models.length).toBe(5)
|
||||
const names = data.models.map((m: any) => m.name)
|
||||
expect(names).toContain('Fable 5.1')
|
||||
expect(names).toContain('Fable 5')
|
||||
expect(names).toContain('Opus 4.8')
|
||||
expect(names).toContain('Sonnet 5')
|
||||
@@ -398,6 +399,19 @@ describe('Business Flow: Models & Effort', () => {
|
||||
expect(data.model.name).toBe('Opus 4.8')
|
||||
})
|
||||
|
||||
it('should select Fable 5.1 with its reasoning catalog intact', async () => {
|
||||
const { status } = await api('PUT', '/api/models/current', {
|
||||
modelId: 'claude-fable-5-1',
|
||||
})
|
||||
expect(status).toBe(200)
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
expect(data.model).toMatchObject({
|
||||
id: 'claude-fable-5-1', name: 'Fable 5.1', context: '1m',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to Haiku 4.5', async () => {
|
||||
await api('PUT', '/api/models/current', { modelId: 'claude-haiku-4-5' })
|
||||
const { data } = await api('GET', '/api/models/current')
|
||||
|
||||
@@ -216,8 +216,8 @@ describe('E2E: Full Flow', () => {
|
||||
|
||||
it('should list available models', async () => {
|
||||
const { data } = await api('GET', '/api/models')
|
||||
expect(data.models.length).toBe(4)
|
||||
expect(data.models[0].name).toBe('Fable 5')
|
||||
expect(data.models.length).toBe(5)
|
||||
expect(data.models[0].name).toBe('Fable 5.1')
|
||||
})
|
||||
|
||||
it('should switch model', async () => {
|
||||
|
||||
@@ -713,6 +713,14 @@ describe('Models API', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.models).toEqual([
|
||||
{
|
||||
id: 'claude-fable-5-1',
|
||||
name: 'Fable 5.1',
|
||||
description: 'Highest capability for long-running tasks',
|
||||
context: '1m',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Fable 5',
|
||||
@@ -971,6 +979,7 @@ describe('Models API', () => {
|
||||
)
|
||||
const listBody = await listResponse.json()
|
||||
expect(listBody.models.map((model: { id: string }) => model.id)).toEqual([
|
||||
'claude-fable-5-1',
|
||||
'claude-fable-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-sonnet-5',
|
||||
|
||||
@@ -2250,12 +2250,14 @@ describe('TeamService', () => {
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
})
|
||||
expect(await readTaskListSnapshot(teamName)).toMatchObject({
|
||||
const activeSnapshot = await readTaskListSnapshot(teamName)
|
||||
expect(activeSnapshot.tasks).toHaveLength(2)
|
||||
expect(activeSnapshot).toMatchObject({
|
||||
revision: 2,
|
||||
tasks: [
|
||||
{ id: '1', subject: 'Only generation-two task' },
|
||||
{ id: '2', subject: 'Active generation-two writer' },
|
||||
],
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: '1', subject: 'Only generation-two task' }),
|
||||
expect.objectContaining({ id: '2', subject: 'Active generation-two writer' }),
|
||||
]),
|
||||
})
|
||||
} finally {
|
||||
writerResource.emitDestroy()
|
||||
|
||||
@@ -50,6 +50,14 @@ import {
|
||||
// ─── Fallback models (used when no provider is configured) ────────────────────
|
||||
|
||||
const DEFAULT_MODELS = [
|
||||
{
|
||||
id: 'claude-fable-5-1',
|
||||
name: 'Fable 5.1',
|
||||
description: 'Highest capability for long-running tasks',
|
||||
context: '1m',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Fable 5',
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
} from "src/bootstrap/state.js";
|
||||
import {
|
||||
AFK_MODE_BETA_HEADER,
|
||||
THINKING_BINDING_CONTROLS_BETA_HEADER,
|
||||
CONTEXT_1M_BETA_HEADER,
|
||||
CONTEXT_MANAGEMENT_BETA_HEADER,
|
||||
EFFORT_BETA_HEADER,
|
||||
@@ -191,6 +192,7 @@ import {
|
||||
import { endQueryProfile, queryCheckpoint } from "src/utils/queryProfiler.js";
|
||||
import {
|
||||
modelSupportsAdaptiveThinking,
|
||||
modelUsesBoundThinking,
|
||||
modelSupportsThinking,
|
||||
resolveModelThinkingEnabled,
|
||||
shouldSendExplicitDisabledThinking,
|
||||
@@ -1751,7 +1753,8 @@ async function* queryModel(
|
||||
// setting that can greatly affect model quality and bashing.
|
||||
if (hasThinking && modelCanThink) {
|
||||
if (
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) &&
|
||||
(modelUsesBoundThinking(options.model) ||
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING)) &&
|
||||
modelSupportsAdaptiveThinking(options.model)
|
||||
) {
|
||||
// For models that support adaptive thinking, always use adaptive
|
||||
@@ -1781,6 +1784,19 @@ async function* queryModel(
|
||||
} as unknown as BetaMessageStreamParams['thinking']
|
||||
}
|
||||
|
||||
if (thinking?.type === 'adaptive' && modelUsesBoundThinking(options.model)) {
|
||||
// Directory, tool and compacted-history updates can invalidate old thinking.
|
||||
// Let the API retain valid blocks and drop only those bound to an old prefix.
|
||||
// This header is required for compatibility even when optional betas are disabled.
|
||||
thinking = {
|
||||
...thinking,
|
||||
block_binding: { prefix_mismatch_behavior: 'drop_block' },
|
||||
} as typeof thinking
|
||||
if (!betasParams.includes(THINKING_BINDING_CONTROLS_BETA_HEADER)) {
|
||||
betasParams.push(THINKING_BINDING_CONTROLS_BETA_HEADER)
|
||||
}
|
||||
}
|
||||
|
||||
// Get API context management strategies if enabled
|
||||
const contextManagement = getAPIContextManagement({
|
||||
hasThinking,
|
||||
|
||||
@@ -10,13 +10,18 @@ import { enableConfigs } from '../../utils/config.js'
|
||||
import { get3PModelCapabilityOverride } from '../../utils/model/modelSupportOverrides.js'
|
||||
import { buildProviderManagedEnv } from '../../server/services/providerRuntimeEnv.js'
|
||||
import type { SavedProvider } from '../../server/types/provider.js'
|
||||
import { queryWithModel } from './claude.js'
|
||||
import { queryModelWithStreaming, queryWithModel } from './claude.js'
|
||||
import { createUserMessage } from '../../utils/messages.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
|
||||
function sseEvent(name: string, data: unknown): string {
|
||||
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
function successfulResponse(model: string): string {
|
||||
function successfulResponse(model: string, withThinking = false): string {
|
||||
const textIndex = withThinking ? 1 : 0
|
||||
return [
|
||||
sseEvent('message_start', {
|
||||
type: 'message_start',
|
||||
@@ -31,17 +36,28 @@ function successfulResponse(model: string): string {
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
...(withThinking ? [
|
||||
sseEvent('content_block_start', {
|
||||
type: 'content_block_start', index: 0,
|
||||
content_block: { type: 'thinking', thinking: '', signature: '' },
|
||||
}),
|
||||
sseEvent('content_block_delta', {
|
||||
type: 'content_block_delta', index: 0,
|
||||
delta: { type: 'signature_delta', signature: 'fixture-signature' },
|
||||
}),
|
||||
sseEvent('content_block_stop', { type: 'content_block_stop', index: 0 }),
|
||||
] : []),
|
||||
sseEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
index: textIndex,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
sseEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
index: textIndex,
|
||||
delta: { type: 'text_delta', text: 'OK' },
|
||||
}),
|
||||
sseEvent('content_block_stop', { type: 'content_block_stop', index: 0 }),
|
||||
sseEvent('content_block_stop', { type: 'content_block_stop', index: textIndex }),
|
||||
sseEvent('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
@@ -298,6 +314,7 @@ async function captureQueryRequest({
|
||||
globalThinkingEnabled,
|
||||
provider,
|
||||
responseFactory,
|
||||
continuationSystemPrompts,
|
||||
env,
|
||||
}: {
|
||||
model: string
|
||||
@@ -307,7 +324,8 @@ async function captureQueryRequest({
|
||||
configureCapabilityOverrides?: boolean
|
||||
globalThinkingEnabled?: boolean
|
||||
provider?: SavedProvider
|
||||
responseFactory?: (model: string, body: Record<string, unknown>) => Response
|
||||
responseFactory?: (model: string, body: Record<string, unknown>, headers: Headers) => Response
|
||||
continuationSystemPrompts?: string[][]
|
||||
env?: Readonly<Record<string, string | undefined>>
|
||||
}): Promise<{
|
||||
content: unknown
|
||||
@@ -325,7 +343,7 @@ async function captureQueryRequest({
|
||||
requestHeaders.push(new Headers(request.headers))
|
||||
const body = await request.json() as Record<string, unknown>
|
||||
requests.push(body)
|
||||
return responseFactory?.(model, body) ?? new Response(successfulResponse(model), {
|
||||
return responseFactory?.(model, body, request.headers) ?? new Response(successfulResponse(model), {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
})
|
||||
},
|
||||
@@ -386,19 +404,46 @@ async function captureQueryRequest({
|
||||
clearCapabilityCache()
|
||||
enableConfigs()
|
||||
|
||||
const result = await queryWithModel({
|
||||
userPrompt: 'Reply exactly OK',
|
||||
signal: new AbortController().signal,
|
||||
options: {
|
||||
model,
|
||||
querySource: 'insights',
|
||||
agents: [],
|
||||
isNonInteractiveSession: true,
|
||||
hasAppendSystemPrompt: false,
|
||||
mcpTools: [],
|
||||
effortValue,
|
||||
},
|
||||
})
|
||||
const options = {
|
||||
model,
|
||||
querySource: 'insights' as const,
|
||||
agents: [],
|
||||
isNonInteractiveSession: true,
|
||||
hasAppendSystemPrompt: false,
|
||||
mcpTools: [],
|
||||
effortValue,
|
||||
}
|
||||
let result
|
||||
if (continuationSystemPrompts) {
|
||||
const history: Message[] = []
|
||||
for (const [index, systemPrompt] of [[], ...continuationSystemPrompts].entries()) {
|
||||
history.push(createUserMessage({ content: index === 0 ? 'Reply exactly OK' : 'Continue' }))
|
||||
const assistants = []
|
||||
for await (const message of queryModelWithStreaming({
|
||||
messages: history,
|
||||
systemPrompt: asSystemPrompt(systemPrompt),
|
||||
thinkingConfig: { type: 'disabled' },
|
||||
tools: [],
|
||||
signal: new AbortController().signal,
|
||||
options: {
|
||||
...options,
|
||||
enablePromptCaching: false,
|
||||
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
|
||||
},
|
||||
})) {
|
||||
if (message.type === 'assistant') assistants.push(message)
|
||||
}
|
||||
history.push(...assistants)
|
||||
result = assistants.at(-1)
|
||||
}
|
||||
} else {
|
||||
result = await queryWithModel({
|
||||
userPrompt: 'Reply exactly OK',
|
||||
signal: new AbortController().signal,
|
||||
options,
|
||||
})
|
||||
}
|
||||
if (!result) throw new Error('No assistant response received')
|
||||
|
||||
return {
|
||||
content: result.message.content,
|
||||
@@ -541,6 +586,51 @@ test('normalizes a disabled parent thinking mode to adaptive for Fable', async (
|
||||
expect(requests[0]?.thinking).not.toEqual({ type: 'disabled' })
|
||||
}, 10_000)
|
||||
|
||||
for (const [disableBetas, disableAdaptive] of [[false, false], [true, false], [false, true]]) {
|
||||
test(`keeps Fable 5.1 replay usable after a system change (disable betas: ${disableBetas}, adaptive: ${disableAdaptive})`, async () => {
|
||||
let originalSystem: string | undefined
|
||||
let responseIndex = 0
|
||||
const { content, requests, requestHeaders } = await captureQueryRequest({
|
||||
model: 'claude-fable-5-1',
|
||||
configureCapabilityOverrides: false,
|
||||
continuationSystemPrompts: [[], ['Additional working directories: /tmp/project-two']],
|
||||
env: {
|
||||
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: disableBetas ? '1' : undefined,
|
||||
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: disableAdaptive ? '1' : undefined,
|
||||
},
|
||||
responseFactory: (model, body, headers) => {
|
||||
const system = JSON.stringify(body.system)
|
||||
const changed = originalSystem !== undefined && system !== originalSystem
|
||||
originalSystem ??= system
|
||||
const thinking = body.thinking as { block_binding?: { prefix_mismatch_behavior?: string } }
|
||||
if (changed && (
|
||||
thinking?.block_binding?.prefix_mismatch_behavior !== 'drop_block' ||
|
||||
!headers.get('anthropic-beta')?.includes('thinking-binding-controls-2026-08-01')
|
||||
)) {
|
||||
return Response.json({ type: 'error', error: {
|
||||
type: 'invalid_request_error', message: 'The block is bound to a different conversation',
|
||||
} }, { status: 400 })
|
||||
}
|
||||
const response = successfulResponse(model, true).replaceAll('msg_required_thinking', `msg_replay_${responseIndex++}`)
|
||||
return new Response(response, { headers: { 'content-type': 'text/event-stream' } })
|
||||
},
|
||||
})
|
||||
|
||||
expect(content).toContainEqual({ type: 'text', text: 'OK' })
|
||||
expect(requests).toHaveLength(3)
|
||||
for (let index = 0; index < requests.length; index++) {
|
||||
expect(requests[index]?.model).toBe('claude-fable-5-1')
|
||||
expect(requests[index]?.thinking).toEqual({
|
||||
type: 'adaptive', block_binding: { prefix_mismatch_behavior: 'drop_block' },
|
||||
})
|
||||
expect(requestHeaders[index]?.get('anthropic-beta')).toContain('thinking-binding-controls-2026-08-01')
|
||||
}
|
||||
for (const request of requests.slice(1)) {
|
||||
expect(JSON.stringify(request.messages)).toContain('fixture-signature')
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
test('drops a tool call truncated at the output-token boundary', async () => {
|
||||
const { content, apiError, error, requests } = await captureQueryRequest({
|
||||
model: 'deepseek-v4-flash',
|
||||
|
||||
@@ -478,27 +478,30 @@ describe('Task tool execution ordering', () => {
|
||||
expect(listed.data.taskListSnapshotRevision).toBe(2)
|
||||
expect(updated.data.taskListMutationRevision).toBe(3)
|
||||
expect(afterUpdate.data.taskListSnapshotRevision).toBe(3)
|
||||
expect(listed.data.tasks).toEqual([
|
||||
expect(listed.data.tasks).toHaveLength(2)
|
||||
expect(listed.data.tasks).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: taskId, status: 'pending' }),
|
||||
expect.objectContaining({ id: dependent.data.task.id, status: 'pending' }),
|
||||
])
|
||||
expect(afterUpdate.data.tasks).toEqual([
|
||||
]))
|
||||
expect(afterUpdate.data.tasks).toHaveLength(2)
|
||||
expect(afterUpdate.data.tasks).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: taskId, status: 'in_progress' }),
|
||||
expect.objectContaining({
|
||||
id: dependent.data.task.id,
|
||||
blockedBy: [taskId],
|
||||
}),
|
||||
])
|
||||
]))
|
||||
|
||||
appState = {
|
||||
...appState,
|
||||
teamContext: { teamName: taskListId },
|
||||
}
|
||||
const deleted = await TeamDeleteTool.call({}, context)
|
||||
expect(deleted.data.finalTasks).toHaveLength(2)
|
||||
expect(deleted.data).toMatchObject({
|
||||
success: true,
|
||||
team_name: taskListId,
|
||||
finalTasks: [
|
||||
finalTasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: taskId,
|
||||
status: 'in_progress',
|
||||
@@ -508,7 +511,7 @@ describe('Task tool execution ordering', () => {
|
||||
id: dependent.data.task.id,
|
||||
blockedBy: [taskId],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
})
|
||||
expect(Number.isFinite(Date.parse(deleted.data.taskListSnapshotAt ?? ''))).toBe(true)
|
||||
expect(deleted.data.taskListSnapshotRevision).toBe(3)
|
||||
|
||||
@@ -153,6 +153,7 @@ export function sanitizeSurfaceKey(surfaceKey: string): string {
|
||||
*/
|
||||
export function sanitizeModelName(shortName: string): string {
|
||||
// Map internal variants to public equivalents based on model family
|
||||
if (shortName.includes('fable-5-1')) return 'claude-fable-5-1'
|
||||
if (shortName.includes('fable-5')) return 'claude-fable-5'
|
||||
if (shortName.includes('opus-4-8')) return 'claude-opus-4-8'
|
||||
if (shortName.includes('opus-4-6')) return 'claude-opus-4-7'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user