Merge pull request #74 from IronRookieCoder/fix/sdd-design-invalid-tool-params

Fix/sdd design invalid tool params
This commit is contained in:
geroge 2026-05-13 19:33:47 +08:00 committed by GitHub
commit 3cf17ad756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 505 additions and 82 deletions

View File

@ -49,11 +49,13 @@ import { AbortError, errorMessage, toError } from 'src/utils/errors.js';
import type { CacheSafeParams } from 'src/utils/forkedAgent.js';
import { lazySchema } from 'src/utils/lazySchema.js';
import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from 'src/utils/messages.js';
import type { AgentModelAlias } from 'src/utils/model/agent.js';
import { getAgentModel } from 'src/utils/model/agent.js';
import { permissionModeSchema } from 'src/utils/permissions/PermissionMode.js';
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js';
import { filterDeniedAgents, getDenyRuleForAgent } from 'src/utils/permissions/permissions.js';
import { enqueueSdkEvent } from 'src/utils/sdkEventQueue.js';
import { semanticBoolean } from 'src/utils/semanticBoolean.js';
import { writeAgentMetadata } from 'src/utils/sessionStorage.js';
import { sleep } from 'src/utils/sleep.js';
import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js';
@ -140,15 +142,17 @@ const baseInputSchema = lazySchema(() =>
prompt: z.string().describe('The task for the agent to perform'),
subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'),
model: z
.enum(['sonnet', 'opus', 'haiku'])
.enum(['sonnet', 'opus', 'haiku', 'inherit'])
.optional()
.describe(
"Optional model override for this agent. Takes precedence over the agent definition's model frontmatter. If omitted, uses the agent definition's model, or inherits from the parent.",
'Optional model override for this agent. Takes precedence over the agent definition\'s model frontmatter. Use "inherit" to inherit from the parent. If omitted, uses the agent definition\'s model, or inherits from the parent.',
),
run_in_background: z
.boolean()
.optional()
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
run_in_background: semanticBoolean(z.boolean().optional()).describe(
'Set to true to run this agent in the background. You will be notified when it completes.',
),
fork: semanticBoolean(z.boolean().optional()).describe(
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
),
}),
);
@ -193,23 +197,17 @@ const fullInputSchema = lazySchema(() => {
// which always includes all optional fields.
export const inputSchema = lazySchema(() => {
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
// was removed in 906da6c723): the divergence window is one-session-per-
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
// "schema shows a no-op param" (gate flips on mid-session: param ignored
// by forceAsync) or "schema hides a param that would've worked" (gate
// flips off mid-session: everything still runs async via memoized
// forceAsync). No Zod rejection, no crash — unlike required→optional.
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
const backgroundSchema = isBackgroundTasksDisabled ? schema.omit({ run_in_background: true }) : schema;
return isForkSubagentEnabled() ? backgroundSchema : backgroundSchema.omit({ fork: true });
});
type InputSchema = ReturnType<typeof inputSchema>;
// Explicit type widens the schema inference to always include all optional
// fields even when .omit() strips them for gating (cwd, run_in_background).
// subagent_type is optional; call() defaults it to general-purpose when the
// fork gate is off, or routes to the fork path when the gate is on.
// subagent_type is optional; call() defaults it to general-purpose.
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
fork?: boolean;
name?: string;
team_name?: string;
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
@ -323,6 +321,7 @@ export const AgentTool = buildTool({
{
prompt,
subagent_type,
fork,
description,
model: modelParam,
run_in_background,
@ -338,7 +337,7 @@ export const AgentTool = buildTool({
onProgress?,
) {
const startTime = Date.now();
const model = isCoordinatorMode() ? undefined : modelParam;
const model: AgentModelAlias | undefined = isCoordinatorMode() ? undefined : modelParam;
// Get app state for permission mode and agent filtering
const appState = toolUseContext.getAppState();
@ -407,12 +406,11 @@ export const AgentTool = buildTool({
return { data: spawnResult } as unknown as { data: Output };
}
// Fork subagent experiment routing:
// - subagent_type set: use it (explicit wins)
// - subagent_type omitted, gate on: fork path (undefined)
// - subagent_type omitted, gate off: default general-purpose
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
const isForkPath = effectiveType === undefined;
// Fork routing: explicit `fork: true` parameter triggers the fork path
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
// subagent_type is ignored when fork takes effect.
const isForkPath = fork === true && isForkSubagentEnabled();
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
let selectedAgent: AgentDefinition;
if (isForkPath) {
@ -693,10 +691,6 @@ export const AgentTool = buildTool({
// dependency issues during test module loading.
const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false;
// Fork subagent experiment: force ALL spawns async for a unified
// <task-notification> interaction model (not just fork spawns — all of them).
const forceAsync = isForkSubagentEnabled();
// Assistant mode: force all agents async. Synchronous subagents hold the
// main loop's turn open until they complete — the daemon's inputQueue
// backs up, and the first overdue cron catch-up on spawn becomes N
@ -710,7 +704,6 @@ export const AgentTool = buildTool({
(run_in_background === true ||
selectedAgent.background === true ||
isCoordinator ||
forceAsync ||
assistantForceAsync ||
(proactiveModule?.isProactiveActive() ?? false)) &&
!isBackgroundTasksDisabled;
@ -890,7 +883,7 @@ export const AgentTool = buildTool({
toolUseContext,
rootSetAppState,
agentIdForCleanup: asyncAgentId,
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
getWorktreeResult: cleanupWorktreeIfNeeded,
}),
),

View File

@ -22,7 +22,7 @@ import { getSearchOrReadFromContent, getSearchReadSummaryText } from 'src/utils/
import { getDisplayPath } from 'src/utils/file.js';
import { formatDuration, formatNumber } from 'src/utils/format.js';
import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from 'src/utils/messages.js';
import type { ModelAlias } from 'src/utils/model/aliases.js';
import type { AgentModelAlias } from 'src/utils/model/agent.js';
import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from 'src/utils/model/model.js';
import type { Theme, ThemeName } from 'src/utils/theme.js';
import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js';
@ -435,12 +435,12 @@ export function renderToolUseTag(
description: string;
prompt: string;
subagent_type: string;
model?: ModelAlias;
model?: AgentModelAlias;
}>,
): React.ReactNode {
const tags: React.ReactNode[] = [];
if (input.model) {
if (input.model && input.model !== 'inherit') {
const mainModel = getMainLoopModel();
const agentModel = parseUserSpecifiedModel(input.model);
if (agentModel !== mainModel) {

View File

@ -0,0 +1,203 @@
import { mock, describe, expect, test } from 'bun:test'
import { zodToJsonSchema } from 'src/utils/zodToJsonSchema.js'
// ─── Mocks ───
// Enable FORK_SUBAGENT to match the scenario where the bug manifests.
// Note: due to Bun mock.module isolation across test files, fork field may
// be absent in schema despite isForkSubagentEnabled() returning true.
// The core fix under test is: run_in_background is NOT removed when
// FORK_SUBAGENT is enabled.
mock.module('bun:bundle', () => ({
feature: (flag: string) => {
if (flag === 'FORK_SUBAGENT') return true
if (flag === 'KAIROS') return true
return false
},
}))
const noop = () => {}
mock.module('src/constants/xml.js', () => ({ FORK_BOILERPLATE_TAG: '', FORK_DIRECTIVE_PREFIX: '' }))
mock.module('src/bootstrap/state.js', () => ({ getIsNonInteractiveSession: () => false }))
mock.module('src/coordinator/coordinatorMode.js', () => ({ isCoordinatorMode: () => false }))
mock.module('src/utils/debug.js', () => ({ logForDebugging: noop }))
mock.module('src/utils/messages.js', () => ({ createUserMessage: noop }))
import { inputSchema } from '../AgentTool.js'
describe('AgentTool inputSchema — SDD parallel agent fix verification', () => {
// ─── Core fix: run_in_background must always be accepted ───
test('accepts run_in_background: true (SDD parallel launch)', () => {
const result = inputSchema().safeParse({
description: '生成数据模型设计文档',
prompt: '设计数据模型...',
subagent_type: 'general-purpose',
run_in_background: true,
})
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).run_in_background).toBe(true)
}
})
test('advertises run_in_background in the API JSON schema', () => {
const schema = zodToJsonSchema(inputSchema())
const properties = schema.properties as Record<string, unknown> | undefined
expect(properties?.run_in_background).toMatchObject({
type: 'boolean',
})
})
test('accepts run_in_background: "true" string (semanticBoolean)', () => {
const result = inputSchema().safeParse({
description: '生成API接口设计文档',
prompt: '设计API接口...',
subagent_type: 'general-purpose',
run_in_background: 'true',
})
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).run_in_background).toBe(true)
}
})
test('accepts run_in_background: "false" string (semanticBoolean)', () => {
const result = inputSchema().safeParse({
description: 'test',
prompt: 'test',
subagent_type: 'general-purpose',
run_in_background: 'false',
})
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).run_in_background).toBe(false)
}
})
test('omitting run_in_background yields undefined', () => {
const result = inputSchema().safeParse({
description: 'test',
prompt: 'test',
subagent_type: 'general-purpose',
})
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).run_in_background).toBeUndefined()
}
})
// ─── SDD exact scenario: two background agents launched in parallel ───
test('SDD: two parallel design agents both pass validation', () => {
const agent1 = inputSchema().safeParse({
description: '生成数据模型设计文档',
prompt: '设计数据模型...',
subagent_type: 'general-purpose',
run_in_background: true,
})
const agent2 = inputSchema().safeParse({
description: '生成API接口设计文档',
prompt: '设计API接口...',
subagent_type: 'general-purpose',
run_in_background: true,
})
expect(agent1.success).toBe(true)
expect(agent2.success).toBe(true)
if (agent1.success) {
expect((agent1.data as Record<string, unknown>).run_in_background).toBe(true)
}
if (agent2.success) {
expect((agent2.data as Record<string, unknown>).run_in_background).toBe(true)
}
})
test('SDD: subschema accepts parallel agents without subagent_type (Explore/Plan)', () => {
// SDD uses Explore and Plan agent types
for (const agentType of ['general-purpose', 'Explore', 'Plan']) {
const result = inputSchema().safeParse({
description: `SDD ${agentType} agent`,
prompt: 'design task',
subagent_type: agentType,
run_in_background: true,
})
expect(result.success).toBe(true)
}
})
// ─── Fork parameter basic acceptance ───
test('fork: true does not cause rejection', () => {
const result = inputSchema().safeParse({
description: 'fork test',
prompt: 'do something',
fork: true,
})
// fork may be stripped from output if schema omits it (mock isolation),
// but validation must succeed — this is the key behavior.
expect(result.success).toBe(true)
})
test('fork + run_in_background together do not cause rejection', () => {
const result = inputSchema().safeParse({
description: 'background fork',
prompt: 'parallel work',
fork: true,
run_in_background: true,
})
expect(result.success).toBe(true)
})
// ─── Model parameter ───
test('accepts model: "inherit"', () => {
const result = inputSchema().safeParse({
description: 'test',
prompt: 'test',
subagent_type: 'general-purpose',
model: 'inherit',
})
expect(result.success).toBe(true)
})
test('accepts model: "sonnet" / "opus" / "haiku"', () => {
for (const m of ['sonnet', 'opus', 'haiku'] as const) {
const result = inputSchema().safeParse({
description: 'test',
prompt: 'test',
subagent_type: 'general-purpose',
model: m,
})
expect(result.success).toBe(true)
}
})
test('rejects invalid model value', () => {
const result = inputSchema().safeParse({
description: 'test',
prompt: 'test',
subagent_type: 'general-purpose',
model: 'gpt-5',
})
expect(result.success).toBe(false)
})
// ─── Required fields ───
test('rejects missing description', () => {
const result = inputSchema().safeParse({
prompt: 'test',
subagent_type: 'general-purpose',
})
expect(result.success).toBe(false)
})
test('rejects missing prompt', () => {
const result = inputSchema().safeParse({
description: 'test',
subagent_type: 'general-purpose',
})
expect(result.success).toBe(false)
})
})

View File

@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const agentToolSource = readFileSync(
join(__dirname, '..', 'AgentTool.tsx'),
'utf-8',
)
describe('AgentTool input schema source', () => {
test('accepts inherit as an explicit model option', () => {
expect(agentToolSource).toContain(
".enum(['sonnet', 'opus', 'haiku', 'inherit'])",
)
})
test('uses semantic booleans for background and fork parameters', () => {
expect(agentToolSource).toContain(
'run_in_background: semanticBoolean(z.boolean().optional())',
)
expect(agentToolSource).toContain(
'fork: semanticBoolean(z.boolean().optional())',
)
})
test('does not remove run_in_background when fork is enabled', () => {
expect(agentToolSource).not.toContain(
'isBackgroundTasksDisabled || isForkSubagentEnabled()',
)
expect(agentToolSource).toContain(
'return isForkSubagentEnabled() ? backgroundSchema : backgroundSchema.omit({ fork: true });',
)
})
test('routes fork only through explicit fork parameter', () => {
expect(agentToolSource).toContain(
'const isForkPath = fork === true && isForkSubagentEnabled();',
)
expect(agentToolSource).toContain(
'const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;',
)
})
})

View File

@ -19,11 +19,11 @@ import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
* Fork subagent feature gate.
*
* When enabled:
* - `subagent_type` becomes optional on the Agent tool schema
* - Omitting `subagent_type` triggers an implicit fork: the child inherits
* the parent's full conversation context and system prompt
* - All agent spawns run in the background (async) for a unified
* `<task-notification>` interaction model
* - `fork: true` becomes available on the Agent tool schema
* - Explicit `fork: true` triggers a fork: the child inherits the parent's
* full conversation context and system prompt
* - Forked agents use the same foreground/background behavior as other agents:
* `run_in_background: true` runs async, otherwise they run synchronously
* - `/fork <directive>` slash command is available
*
* Mutually exclusive with coordinator mode coordinator already owns the
@ -60,7 +60,7 @@ export const FORK_SUBAGENT_TYPE = 'fork'
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
whenToUse:
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
'Fork — inherits full conversation context. Not selectable via subagent_type; triggered by fork: true when the fork experiment is active.',
tools: ['*'],
maxTurns: 200,
model: 'inherit',

View File

@ -83,11 +83,11 @@ export async function getPrompt(
## When to fork
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. Add \`run_in_background: true\` only when the fork can proceed independently; otherwise the fork runs in the foreground and returns its result in the same turn. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it.
**Don't peek at background forks.** A background fork tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it.
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results. If the user asks a follow-up before the notification lands, tell them the fork is still running.
**Don't race background forks.** After launching a background fork, you know nothing about what the fork found. Never fabricate or predict fork results. If the user asks a follow-up before the notification lands, tell them the fork is still running.
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* what to do, not what the situation is. Be specific about scope. Don't re-explain background.
`
@ -180,6 +180,7 @@ Usage notes:
: ''
}
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each non-fork Agent invocation starts without context provide a complete task description.' : 'Each Agent invocation starts fresh provide a complete task description.'}
- If you must wait for a background agent's output before proceeding, use TaskOutput with the returned agentId as \`task_id\`. Do not use Bash to sleep, poll, tail, or cat the output file.
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.

View File

@ -56,14 +56,13 @@ import { registerFrontmatterHooks } from 'src/utils/hooks/registerFrontmatterHoo
import { clearSessionHooks } from 'src/utils/hooks/sessionHooks.js'
import { executeSubagentStartHooks } from 'src/utils/hooks.js'
import { createUserMessage } from 'src/utils/messages.js'
import { getAgentModel } from 'src/utils/model/agent.js'
import { getAgentModel, type AgentModelAlias } from 'src/utils/model/agent.js'
import { getAPIProvider } from 'src/utils/model/providers.js'
import {
createSubagentTrace,
endTrace,
isLangfuseEnabled,
} from 'src/services/langfuse/index.js'
import type { ModelAlias } from 'src/utils/model/aliases.js'
import {
clearAgentTranscriptSubdir,
recordSidechainTranscript,
@ -294,7 +293,7 @@ export async function* runAgent({
abortController?: AbortController
agentId?: AgentId
}
model?: ModelAlias
model?: AgentModelAlias
maxTurns?: number
/** Preserve toolUseResult on messages for subagents with viewable transcripts */
preserveToolUseResults?: boolean

View File

@ -30,39 +30,116 @@ import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
import { TASK_UPDATE_TOOL_NAME } from './constants.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
type TaskUpdateStatus = TaskStatus | 'deleted'
const TASK_UPDATE_STATUS_ALIASES: Record<string, TaskUpdateStatus> = {
open: 'pending',
todo: 'pending',
started: 'in_progress',
active: 'in_progress',
in_progress: 'in_progress',
inprogress: 'in_progress',
complete: 'completed',
completed: 'completed',
done: 'completed',
resolved: 'completed',
closed: 'completed',
deleted: 'deleted',
delete: 'deleted',
removed: 'deleted',
}
function normalizeTaskUpdateStatus(value: unknown): unknown {
if (typeof value !== 'string') return value
const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_')
return TASK_UPDATE_STATUS_ALIASES[key] ?? value
}
function normalizeTaskId(value: unknown): unknown {
return typeof value === 'number' ? String(value) : value
}
function normalizeTaskUpdateInput(value: unknown): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const input = value as Record<string, unknown>
const normalized: Record<string, unknown> = { ...input }
if (normalized.taskId === undefined) {
normalized.taskId = normalizeTaskId(input.task_id ?? input.id)
} else {
normalized.taskId = normalizeTaskId(normalized.taskId)
}
normalized.status = normalizeTaskUpdateStatus(normalized.status)
if (normalized.activeForm === undefined) {
normalized.activeForm = input.active_form
}
if (normalized.addBlocks === undefined) {
normalized.addBlocks = input.add_blocks ?? input.blocks
}
if (normalized.addBlockedBy === undefined) {
normalized.addBlockedBy =
input.add_blocked_by ?? input.blockedBy ?? input.blocked_by
}
delete normalized.task_id
delete normalized.id
delete normalized.active_form
delete normalized.add_blocks
delete normalized.blocks
delete normalized.add_blocked_by
delete normalized.blockedBy
delete normalized.blocked_by
return normalized
}
const inputSchema = lazySchema(() => {
// Extended status schema that includes 'deleted' as a special action
const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted'))
const TaskUpdateStatusSchema = z.preprocess(
normalizeTaskUpdateStatus,
TaskStatusSchema().or(z.literal('deleted')),
)
return z.strictObject({
taskId: z.string().describe('The ID of the task to update'),
subject: z.string().optional().describe('New subject for the task'),
description: z.string().optional().describe('New description for the task'),
activeForm: z
.string()
.optional()
.describe(
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
return z.preprocess(
normalizeTaskUpdateInput,
z.strictObject({
taskId: z.string().describe('The ID of the task to update'),
subject: z.string().optional().describe('New subject for the task'),
description: z
.string()
.optional()
.describe('New description for the task'),
activeForm: z
.string()
.optional()
.describe(
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
),
status: TaskUpdateStatusSchema.optional().describe(
'New status for the task',
),
status: TaskUpdateStatusSchema.optional().describe(
'New status for the task',
),
addBlocks: z
.array(z.string())
.optional()
.describe('Task IDs that this task blocks'),
addBlockedBy: z
.array(z.string())
.optional()
.describe('Task IDs that block this task'),
owner: z.string().optional().describe('New owner for the task'),
metadata: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Metadata keys to merge into the task. Set a key to null to delete it.',
),
})
addBlocks: z
.array(z.string())
.optional()
.describe('Task IDs that this task blocks'),
addBlockedBy: z
.array(z.string())
.optional()
.describe('Task IDs that block this task'),
owner: z.string().optional().describe('New owner for the task'),
metadata: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Metadata keys to merge into the task. Set a key to null to delete it.',
),
}),
)
})
type InputSchema = ReturnType<typeof inputSchema>

View File

@ -0,0 +1,98 @@
import { describe, expect, mock, test } from 'bun:test'
import { z } from 'zod/v4'
mock.module('bun:bundle', () => ({
feature: () => false,
}))
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
}))
mock.module('src/utils/hooks.js', () => ({
executeTaskCompletedHooks: async function* () {},
getTaskCompletedHookMessage: (message: unknown) => String(message),
}))
mock.module('src/utils/tasks.js', () => ({
blockTask: async () => true,
deleteTask: async () => true,
getTask: async () => null,
getTaskListId: () => 'test-list',
isTodoV2Enabled: () => true,
listTasks: async () => [],
TaskStatusSchema: () => z.enum(['pending', 'in_progress', 'completed']),
updateTask: async () => null,
}))
mock.module('src/utils/agentSwarmsEnabled.js', () => ({
isAgentSwarmsEnabled: () => false,
}))
mock.module('src/utils/teammate.js', () => ({
getAgentId: () => undefined,
getAgentName: () => undefined,
getTeammateColor: () => undefined,
getTeamName: () => undefined,
}))
mock.module('src/utils/teammateMailbox.js', () => ({
writeToMailbox: async () => undefined,
}))
import { TaskUpdateTool } from '../TaskUpdateTool.js'
describe('TaskUpdateTool inputSchema', () => {
test('normalizes legacy status aliases', () => {
const schema = TaskUpdateTool.inputSchema
for (const [inputStatus, expectedStatus] of [
['open', 'pending'],
['in progress', 'in_progress'],
['resolved', 'completed'],
['done', 'completed'],
['removed', 'deleted'],
] as const) {
const result = schema.safeParse({
taskId: '1',
status: inputStatus,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.status).toBe(expectedStatus)
}
}
})
test('normalizes common field aliases before strict validation', () => {
const result = TaskUpdateTool.inputSchema.safeParse({
task_id: 1,
status: 'resolved',
active_form: 'Running tests',
blocks: ['3'],
blocked_by: ['2'],
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toEqual({
taskId: '1',
status: 'completed',
activeForm: 'Running tests',
addBlocks: ['3'],
addBlockedBy: ['2'],
})
}
})
test('still rejects unrelated unknown fields', () => {
const result = TaskUpdateTool.inputSchema.safeParse({
taskId: '1',
status: 'completed',
unexpected: true,
})
expect(result.success).toBe(false)
})
})

View File

@ -4,11 +4,11 @@ export const PROMPT = `Use this tool to update a task in the task list.
## When to Use This Tool
**Mark tasks as resolved:**
**Mark tasks as completed:**
- When you have completed the work described in a task
- When a task is no longer needed or has been superseded
- IMPORTANT: Always mark your assigned tasks as resolved when you finish them
- After resolving, call TaskList to find your next task
- IMPORTANT: Always mark your assigned tasks as completed when you finish them
- After completing, call TaskList to find your next task
- ONLY mark a task as completed when you have FULLY accomplished it
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress

View File

@ -60,7 +60,7 @@ export const DEFAULT_BUILD_FEATURES = [
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
// 'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务(默认关闭,开启后强制所有 agent 异步运行run_in_background 参数失效)
// 'FORK_SUBAGENT', // Fork 子代理,显式 `fork: true` 参数触发,不影响 run_in_background 行为
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'KAIROS', // Kairos 定时任务系统核心
// 'COORDINATOR_MODE', // 已禁用AgentSummary 30s fork 循环GB 级泄露主因

View File

@ -40,7 +40,7 @@ export async function call(
try {
// Reuse AgentTool logic for fork path.
// Omitting subagent_type triggers implicit fork.
// `fork: true` triggers the explicit fork path.
const input = {
prompt: directive,
fork: true, // 触发 AgentTool 的 fork 路径:继承父会话上下文 + system prompt + 模型
@ -52,7 +52,7 @@ export async function call(
};
// Call AgentTool with proper parameters:
// - input: the agent parameters (no subagent_type => fork path)
// - input: the agent parameters (fork: true => fork path)
// - toolUseContext: the current context (ToolUseContext)
// - canUseTool: permission-check function from context
// - assistantMessage: the last assistant message to fork from

View File

@ -37,7 +37,7 @@ export function getDefaultSubagentModel(): string {
export function getAgentModel(
agentModel: string | undefined,
parentModel: string,
toolSpecifiedModel?: ModelAlias,
toolSpecifiedModel?: AgentModelAlias,
permissionMode?: PermissionMode,
): string {
if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) {
@ -68,9 +68,16 @@ export function getAgentModel(
// Prioritize tool-specified model if provided
if (toolSpecifiedModel) {
if (toolSpecifiedModel === 'inherit') {
return getRuntimeMainLoopModel({
permissionMode: permissionMode ?? 'default',
mainLoopModel: parentModel,
exceeds200kTokens: false,
})
}
if (
aliasMatchesParentTier(toolSpecifiedModel, parentModel) ||
// CoStrict has no haiku/sonnet/opus tiers — inherit parent model for all aliases
// CoStrict has no haiku/sonnet/opus tiers, so inherit parent model for family aliases.
(getAPIProvider() === 'costrict' && isModelFamilyAlias(toolSpecifiedModel))
) {
return parentModel