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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/__tests__/bugfixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,75 @@ describe('Agent loop continuation nudge', () => {
'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.',
)
})

test('post-tool stall detection catches empty and placeholder replies', async () => {
const {
conversationHasPendingToolFollowUp,
isStalledPostToolResponse,
isToolResultSemanticPlaceholderText,
shouldNudgePostToolTurn,
stripSemanticPlaceholderAssistants,
} = await import('../utils/continuation.js')

expect(isStalledPostToolResponse('')).toBe(true)
expect(isToolResultSemanticPlaceholderText('[Tool results received]')).toBe(true)
expect(isToolResultSemanticPlaceholderText('● [Tool results received]')).toBe(true)
expect(isStalledPostToolResponse('[Tool results received]')).toBe(true)
expect(isStalledPostToolResponse('● [Tool results received]')).toBe(true)
expect(isStalledPostToolResponse('Task finished')).toBe(false)

expect(
shouldNudgePostToolTurn({
lastText: '',
hadEmptyAssistantResponse: true,
}),
).toBe(true)
expect(
shouldNudgePostToolTurn({
lastText: '[Tool results received]',
hadEmptyAssistantResponse: false,
}),
).toBe(true)
expect(
shouldNudgePostToolTurn({
lastText: 'All tests pass and the fix is complete.',
hadEmptyAssistantResponse: false,
}),
).toBe(false)

const toolRound = [
{
type: 'assistant',
isApiErrorMessage: false,
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }],
},
},
{
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }],
},
},
] as unknown as import('../types/message.js').Message[]
Comment on lines +181 to +197

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid as unknown as in strict-mode test fixtures.

These double-casts bypass message-shape checks and can hide malformed fixtures. Prefer typed literals (e.g., satisfies Message[]) so the regression test fails at compile time when the contract drifts.

As per coding guidelines, "Follow TypeScript strict mode and type safety practices by running typecheck before submitting."

Also applies to: 210-211

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/bugfixes.test.ts` around lines 181 - 197, The test fixture
toolRound uses the double-cast pattern `as unknown as` which bypasses TypeScript
type checking and can hide issues with malformed fixtures. Replace this cast
pattern with a `satisfies Message[]` type assertion on the toolRound array
literal to ensure strict type safety at compile time. Apply the same change to
the other instances mentioned at lines 210-211 that use the same problematic
casting pattern.

Source: Coding guidelines


expect(conversationHasPendingToolFollowUp(toolRound)).toBe(true)
expect(
stripSemanticPlaceholderAssistants([
...toolRound,
{
type: 'assistant',
isApiErrorMessage: false,
message: {
role: 'assistant',
content: [{ type: 'text', text: '[Tool results received]' }],
},
} as unknown as import('../types/message.js').Message,
]).length,
).toBe(2)
})
})

// ---------------------------------------------------------------------------
Expand Down
40 changes: 22 additions & 18 deletions src/commands/ctx_viz/ctx_viz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { stripVTControlCharacters as stripAnsi } from 'node:util'

import {
afterEach,
Expand Down Expand Up @@ -208,46 +209,49 @@ describe('/ctx command surface (PR #1610)', () => {
modelUsageMap: {},
})

// Chalk may inject ANSI sequences when FORCE_COLOR is set (common in CI).
const plainOut = stripAnsi(out)

// Header line — confirms the model name is rendered.
expect(out).toContain('Context Window:')
expect(plainOut).toContain('Context Window:')
// Window Capacity block (4 bullets).
expect(out).toContain('Window Capacity')
expect(out).toContain('Context window:')
expect(out).toContain('Effective context:')
expect(out).toContain('Max output:')
expect(plainOut).toContain('Window Capacity')
expect(plainOut).toContain('Context window:')
expect(plainOut).toContain('Effective context:')
expect(plainOut).toContain('Max output:')
// Auto-compact line is rendered because the fixture sets
// isAutoCompactEnabled: true.
expect(out).toContain('Auto-compact at:')
expect(plainOut).toContain('Auto-compact at:')
// Current Context block + total.
expect(out).toContain('Current Context (what the model sees)')
expect(out).toContain('Total:')
expect(out).toMatch(/used\)/)
expect(plainOut).toContain('Current Context (what the model sees)')
expect(plainOut).toContain('Total:')
expect(plainOut).toMatch(/used\)/)
// Each non-zero category in the fixture appears in the output.
for (const cat of [
'System prompt',
'System tools',
'Memory files',
'Messages',
]) {
expect(out).toContain(cat)
expect(plainOut).toContain(cat)
}
// Bar characters — width 30, ratio = tokens / contextWindow (200k).
// With the fixture:
// System tools 15.5k / 200k → 2 filled
// System prompt 7.8k / 200k → 1 filled
// Memory files 956 / 200k → 0 filled
// Messages 84 / 200k → 0 filled
expect(out).toContain('█'.repeat(2) + '░'.repeat(28))
expect(out).toContain('█'.repeat(1) + '░'.repeat(29))
expect(out).toMatch(/░{30}/)
expect(plainOut).toContain('█'.repeat(2) + '░'.repeat(28))
expect(plainOut).toContain('█'.repeat(1) + '░'.repeat(29))
expect(plainOut).toMatch(/░{30}/)
// Footer cross-references the sibling commands.
expect(out).toContain('/context')
expect(out).toContain('/cost')
expect(out).toContain('/stats')
expect(plainOut).toContain('/context')
expect(plainOut).toContain('/cost')
expect(plainOut).toContain('/stats')
// Capacity rows (Free space, Autocompact buffer, Compact buffer) should be filtered out.
expect(out).not.toContain('Free space')
expect(plainOut).not.toContain('Free space')
// Deferred tool categories (MCP tools (deferred), System tools (deferred))
// should be filtered out since they aren't in the model-visible context.
expect(out).not.toContain('System tools (deferred)')
expect(plainOut).not.toContain('System tools (deferred)')
})
})
47 changes: 32 additions & 15 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ async function waitForCondition(
throw new Error('Timed out waiting for ProviderManager test condition')
}

async function submitSelfHostedCompatStep(
mounted: { stdin: NodeJS.WriteStream; getOutput: () => string },
): Promise<void> {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Self-hosted compat'),
)
mounted.stdin.write('\r')
}

// Provider list is sorted from generated preset metadata by description, with
// Gitlawb Opengateway pinned first, Codex OAuth injected after DeepSeek, and
// Custom always pinned last. Keep the target-by-label indirection here so
Expand Down Expand Up @@ -640,6 +649,7 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
frame.includes('Default model'),
)
mounted.stdin.write('\r')
await submitSelfHostedCompatStep(mounted)

const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API mode') && frame.includes('Chat Completions'),
Expand Down Expand Up @@ -1080,13 +1090,13 @@ test('ProviderManager clears hidden Hicap auth fields when editing', async () =>
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 6'),
frame.includes('Step 1 of 7'),
)

for (let step = 2; step <= 6; step++) {
for (let step = 2; step <= 7; step++) {
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes(`Step ${step} of 6`),
frame.includes(`Step ${step} of 7`),
)
}
mounted.stdin.write('\r')
Expand Down Expand Up @@ -1156,25 +1166,26 @@ test('ProviderManager skips advanced fields for legacy Kimi Code profiles', asyn
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider name') &&
frame.includes('Step 1 of 4'),
frame.includes('Step 1 of 5'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL') &&
frame.includes('Step 2 of 4'),
frame.includes('Step 2 of 5'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model') &&
frame.includes('Step 3 of 4'),
frame.includes('Step 3 of 5'),
)

mounted.stdin.write('\r')
await submitSelfHostedCompatStep(mounted)
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key') &&
frame.includes('Step 4 of 4'),
frame.includes('Step 5 of 5'),
)

expect(output).not.toContain('API mode')
Expand Down Expand Up @@ -2007,49 +2018,55 @@ test('ProviderManager editing an active multi-model provider keeps app state on
mounted.getOutput,
frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 8'),
frame.includes('Step 1 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 8'),
frame => frame.includes('Step 3 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 8'),
frame => frame.includes('Step 4 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 4 of 8'),
frame => frame.includes('Step 5 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 5 of 8'),
frame => frame.includes('Step 6 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 6 of 8'),
frame => frame.includes('Step 7 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 7 of 8'),
frame => frame.includes('Step 8 of 9'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 8 of 8'),
frame => frame.includes('Step 9 of 9'),
)

mounted.stdin.write('\r')
Expand Down
Loading