Skip to content
Open
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
7 changes: 0 additions & 7 deletions packages/config-eslint/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import js from "@eslint/js"
import eslintConfigPrettier from "eslint-config-prettier"
import turboPlugin from "eslint-plugin-turbo"
import tseslint from "typescript-eslint"
import onlyWarn from "eslint-plugin-only-warn"

/**
* A shared ESLint configuration for the repository.
*
Expand All @@ -21,11 +19,6 @@ export const config = [
"turbo/no-undeclared-env-vars": "off",
},
},
{
plugins: {
onlyWarn,
},
},
{
ignores: ["dist/**"],
},
Expand Down
1 change: 0 additions & 1 deletion packages/config-eslint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"@next/eslint-plugin-next": "15.5.20",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-only-warn": "1.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-turbo": "2.10.0",
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 18 additions & 3 deletions src/__tests__/helpers/provider-stub.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { ClineProvider } from "../../core/webview/ClineProvider"
import { TaskRegistry } from "../../core/task/TaskRegistry"
import { type Task } from "../../core/task/Task"

/**
* Augments a plain stub object with the instance fields and bound methods that
* ClineProvider methods read from `this` (runDelegationTransition,
* delegationTransitionLocks, cancelledDelegationChildIds, cancellingDelegationChildIds),
* so tests can call private methods via `(ClineProvider.prototype as any).method.call(stub, …)`
* without instantiating a real ClineProvider.
*
* Pass `tasks` (array of Task mocks) to pre-seed the registry in stack order.
* The legacy `clineStack` key is accepted and converted automatically.
*/
export function makeProviderStub<T extends object>(stub: T): T {
export function makeProviderStub<T extends object>(stub: T): ClineProvider {
const s = stub as any
const proto = ClineProvider.prototype as any
s.delegationTransitionLocks ??= new Map()
s.cancelledDelegationChildIds ??= new Set()
s.log ??= vi.fn()
s.taskHistoryStore ??= { get: () => undefined }
s.runDelegationTransition = proto.runDelegationTransition.bind(s)

// Convert legacy clineStack array into a TaskRegistry
if (!s.taskRegistry) {
const registry = new TaskRegistry()
const seed: Task[] = s.clineStack ?? s.tasks ?? []
for (const t of seed) registry.push(t)
s.taskRegistry = registry
}
delete s.clineStack

s.runDelegationTransition ??= proto.runDelegationTransition.bind(s)
s.removeClineFromStack ??= proto.removeClineFromStack.bind(s)
s.evictCurrentTask ??= proto.evictCurrentTask.bind(s)
return s
return s as unknown as ClineProvider
}
74 changes: 53 additions & 21 deletions src/__tests__/removeClineFromStack-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { describe, it, expect, vi } from "vitest"
import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskRegistry } from "../core/task/TaskRegistry"
import { makeProviderStub } from "./helpers/provider-stub"

// After the refactor: removeClineFromStack() is pure lifecycle — it pops, aborts, and
// After the refactor: removeClineFromStack() is pure lifecycle — it removes the focused task, aborts, and
// cleans up listeners. It does NOT mutate delegation metadata. All delegated→active
// transitions are owned by reopenParentFromDelegation() (normal child completion) or
// markDelegatedChildInterrupted() (live eviction via navigation / new-task / clear).
Expand Down Expand Up @@ -49,17 +50,47 @@ function buildMockProvider(opts: {
}

describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation side effects", () => {
it("pops the task, aborts it, and clears listeners", async () => {
it("removes the focused task, aborts it, and clears listeners", async () => {
const { provider, childTask } = buildMockProvider({ childTaskId: "child-1" })
expect(provider.clineStack).toHaveLength(1)
expect(provider["taskRegistry"].length).toBe(1)

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

expect(provider.clineStack).toHaveLength(0)
expect(provider["taskRegistry"].length).toBe(0)
expect(childTask.abortTask).toHaveBeenCalledWith(true)
expect(childTask.emit).toHaveBeenCalledWith(expect.stringContaining("taskUnfocused"))
})

it("removes the focused task even when it is not the top stack entry", async () => {
const focusedTask = {
taskId: "focused-1",
instanceId: "focused-inst",
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const topTask = {
taskId: "top-1",
instanceId: "top-inst",
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const provider = makeProviderStub({
tasks: [focusedTask, topTask] as any[],
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId: vi.fn(),
updateTaskHistory: vi.fn(),
})
provider["taskRegistry"].setCurrent("focused-1")

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

expect(provider["taskRegistry"].taskIds).toEqual(["top-1"])
expect(provider["taskRegistry"].current).toBe(topTask)
expect(focusedTask.abortTask).toHaveBeenCalledWith(true)
expect(topTask.abortTask).not.toHaveBeenCalled()
})

it("does NOT mutate parent metadata when a delegated child is popped (repair removed)", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
Expand All @@ -74,7 +105,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

expect(provider.clineStack).toHaveLength(0)
expect(provider["taskRegistry"].length).toBe(0)
// Navigation/disposal must never silently flip the parent to active
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
Expand All @@ -94,7 +125,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

expect(provider.clineStack).toHaveLength(0)
expect(provider["taskRegistry"].length).toBe(0)
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
})
Expand All @@ -106,7 +137,7 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation

await (ClineProvider.prototype as any).removeClineFromStack.call(provider)

expect(provider.clineStack).toHaveLength(0)
expect(provider["taskRegistry"].length).toBe(0)
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
})
Expand All @@ -122,8 +153,8 @@ describe("ClineProvider.removeClineFromStack() — pure lifecycle, no delegation

await expect((ClineProvider.prototype as any).removeClineFromStack.call(provider)).resolves.not.toThrow()

expect((provider as any).getTaskWithId).not.toHaveBeenCalled()
expect((provider as any).updateTaskHistory).not.toHaveBeenCalled()
expect(provider["getTaskWithId"]).not.toHaveBeenCalled()
expect(provider["updateTaskHistory"]).not.toHaveBeenCalled()
})
})

Expand Down Expand Up @@ -270,6 +301,9 @@ describe("ClineProvider.markDelegatedChildInterrupted() — live eviction path",
return { historyItem: { id: childTaskId, status: "interrupted", parentTaskId } }
})

const realRunDelegation = ClineProvider.prototype["runDelegationTransition"].bind({
delegationTransitionLocks: new Map(),
})
const provider = makeProviderStub({
clineStack: [] as any[],
taskEventListeners: new Map(),
Expand All @@ -289,15 +323,13 @@ describe("ClineProvider.markDelegatedChildInterrupted() — live eviction path",
return undefined
}),
},
// Wrap the real runDelegationTransition to set lockAcquired before the lock fires
runDelegationTransition: async (parentId: string, fn: () => Promise<void>) => {
lockAcquired = true
return realRunDelegation(parentId, fn)
},
})

// Patch runDelegationTransition to set lockAcquired before calling fn
const realRunDelegation = (provider as any).runDelegationTransition.bind(provider)
;(provider as any).runDelegationTransition = async (_parentId: string, fn: () => Promise<void>) => {
lockAcquired = true
return realRunDelegation(_parentId, fn)
}

await (ClineProvider.prototype as any).markDelegatedChildInterrupted.call(provider, {
childTaskId,
parentTaskId,
Expand Down Expand Up @@ -490,7 +522,7 @@ describe("ClineProvider.evictCurrentTask() — active delegated child path", ()

await (ClineProvider.prototype as any).evictCurrentTask.call(provider)

expect(provider.clineStack).toHaveLength(0)
expect(provider["taskRegistry"].length).toBe(0)
expect(markDelegatedChildInterrupted).toHaveBeenCalledWith({ childTaskId, parentTaskId })
})

Expand Down Expand Up @@ -602,16 +634,16 @@ describe("onTaskCompleted callback — writes completed status before re-emittin
// The real callback is set in the constructor body; we replicate the relevant portion.
const onTaskCompleted = async (taskId: string) => {
try {
const existing = (provider as any).taskHistoryStore.get(taskId)
const existing = provider["taskHistoryStore"].get(taskId)
if (existing && existing.status !== "completed") {
await (provider as any).updateTaskHistory({ ...existing, status: "completed" })
await provider["updateTaskHistory"]({ ...existing, status: "completed" })
}
} catch (err) {
;(provider as any).log(
provider["log"](
`[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`,
)
}
;(provider as any).emit("TaskCompleted", taskId, {}, {})
emit("TaskCompleted", taskId, {}, {})
}

return { onTaskCompleted, updateTaskHistory, emit, log }
Expand Down
14 changes: 9 additions & 5 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { describe, it, expect, vi, beforeEach } from "vitest"
import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskRegistry } from "../core/task/TaskRegistry"
import { API } from "../extension/api"
import * as ProfileValidatorMod from "../shared/ProfileValidator"

Expand Down Expand Up @@ -39,10 +40,11 @@ describe("Single-open-task invariant", () => {
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)

const existingTask = { taskId: "existing-1" }
const existingTask = { taskId: "existing-1", abort: false, abandoned: false }
const registry = new TaskRegistry()
registry.push(existingTask as any)
const provider = {
// Simulate an existing task present in stack
clineStack: [existingTask],
taskRegistry: registry,
getCurrentTask: vi.fn(() => existingTask),
taskHistoryStore: { get: vi.fn(() => undefined) },
markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -85,10 +87,12 @@ describe("Single-open-task invariant", () => {

const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)
const parentTask = { taskId: "parent-1" }
const parentTask = { taskId: "parent-1", abort: false, abandoned: false }
const registry2 = new TaskRegistry()
registry2.push(parentTask as any)

const provider = {
clineStack: [parentTask],
taskRegistry: registry2,
setValues: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
Expand Down
Loading
Loading