Skip to content

Commit 427cd72

Browse files
test(core): answer the mutation rows the digest printed at these coordinates
A mutation row that completed successfully at an earlier head of this branch annotated nine mutants in two files this unit never edits. Reproduced locally at the head this landing lands on, all nine still did: in core/task/Task.ts a boolean literal on each of the two synchronous api-config initialization branches and on the successful metadata stage survived, the same literal inside the rejection observer and the observer's own block were covered by nothing at all, and in core/assistant-message/presentAssistantMessage.ts the conditional that scopes the bypass release to write_to_file survived when forced to always run at each of the three exits. Every answer here is a row under a tests path, so the instrument prices none of it as changed executable lines, and no exclusion directive is written: the rejection observer now has a row whose initialization rejects, the two synchronous branches and the successful metadata stage are asserted on the flags and the return they exist to produce, and the bypass release has a not-called assertion beside each of the three rows that assert it runs for write_to_file.
1 parent 4e2d548 commit 427cd72

3 files changed

Lines changed: 195 additions & 0 deletions

File tree

‎src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,99 @@ describe("presentAssistantMessage - finalized block without nativeArgs", () => {
194194
expect(mockTeardown).toHaveBeenCalledTimes(1)
195195
expect(mockTeardown).toHaveBeenCalledWith(mockTask)
196196
})
197+
198+
it("leaves the stream state alone at the missing-nativeArgs exit for a tool that opened no diff view", async () => {
199+
// The release at this bypass is scoped to the one tool whose streamed diff view a skipped
200+
// handle() would strand. Applying it to every block name would ask other tools to release
201+
// state they never opened, so the guard has to read the name rather than the shape alone.
202+
mockTask.assistantMessageContent = [
203+
{
204+
type: "tool_use",
205+
name: "execute_command",
206+
params: { cmd: "ls" },
207+
partial: false,
208+
id: "toolu_4",
209+
nativeArgs: undefined,
210+
},
211+
]
212+
213+
await presentAssistantMessage(
214+
mockTask as unknown as Parameters<typeof presentAssistantMessage>[0],
215+
baseSnapshot,
216+
)
217+
218+
expect(mockWriteHandle).not.toHaveBeenCalled()
219+
expect(userMessageContent).toEqual([
220+
expect.objectContaining({ type: "tool_result", tool_use_id: "toolu_4", is_error: true }),
221+
])
222+
expect(mockTeardown).not.toHaveBeenCalled()
223+
})
224+
225+
it("leaves the stream state alone at the validation exit for a tool that opened no diff view", async () => {
226+
// A refusal lands here whatever the tool. Only write_to_file has a streamed diff view at
227+
// this point, so the release stays scoped to it.
228+
mockTask.assistantMessageContent = [
229+
{
230+
type: "tool_use",
231+
name: "execute_command",
232+
params: { cmd: "ls" },
233+
partial: false,
234+
id: "toolu_5",
235+
nativeArgs: { cmd: "ls" },
236+
},
237+
]
238+
vi.mocked(validateToolUse).mockImplementationOnce(() => {
239+
throw new Error("execute_command is not allowed in this mode")
240+
})
241+
242+
await presentAssistantMessage(
243+
mockTask as unknown as Parameters<typeof presentAssistantMessage>[0],
244+
baseSnapshot,
245+
)
246+
247+
expect(mockWriteHandle).not.toHaveBeenCalled()
248+
expect(userMessageContent).toEqual([
249+
expect.objectContaining({
250+
type: "tool_result",
251+
tool_use_id: "toolu_5",
252+
is_error: true,
253+
content: expect.stringContaining("not allowed"),
254+
}),
255+
])
256+
expect(mockTeardown).not.toHaveBeenCalled()
257+
})
258+
259+
it("leaves the stream state alone at the repetition exit for a tool that opened no diff view", async () => {
260+
// The repetition stop bypasses handle() for any tool, and the release is still only owed by
261+
// the one that streams a diff view.
262+
mockTask.assistantMessageContent = [
263+
{
264+
type: "tool_use",
265+
name: "execute_command",
266+
params: { cmd: "ls" },
267+
partial: false,
268+
id: "toolu_6",
269+
nativeArgs: { cmd: "ls" },
270+
},
271+
]
272+
vi.mocked(mockTask.toolRepetitionDetector as unknown as { check: unknown }).check = vi.fn().mockReturnValue({
273+
action: "hard_block",
274+
askUser: { messageKey: "tool_repetition", messageDetail: "execute_command" },
275+
})
276+
277+
await presentAssistantMessage(
278+
mockTask as unknown as Parameters<typeof presentAssistantMessage>[0],
279+
baseSnapshot,
280+
)
281+
282+
expect(mockWriteHandle).not.toHaveBeenCalled()
283+
expect(userMessageContent).toEqual([
284+
expect.objectContaining({
285+
type: "tool_result",
286+
tool_use_id: "toolu_6",
287+
content: expect.stringContaining("repetition limit reached"),
288+
}),
289+
])
290+
expect(mockTeardown).not.toHaveBeenCalled()
291+
})
197292
})

‎src/core/task/__tests__/Task.spec.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,79 @@ describe("Cline", () => {
15601560
},
15611561
)
15621562

1563+
it("marks the api-config wait settled for a delegated child handed an explicit context", async () => {
1564+
// The dispose-time metadata repair keys off this flag to decide whether the api config is
1565+
// still being worked out. A child that arrives with its profile named has nothing pending,
1566+
// so the synchronous branch has to settle the wait itself; leaving it pending would make
1567+
// every delegated child read as an unfinished initialization for its whole life.
1568+
const task = new Task({
1569+
provider: mockProvider,
1570+
apiConfiguration: mockApiConfig,
1571+
task: "delegated child",
1572+
startTask: false,
1573+
handoffExecutionContext: {
1574+
mode: "ask",
1575+
apiConfigName: "parent-local-profile",
1576+
apiConfiguration: mockApiConfig,
1577+
},
1578+
})
1579+
1580+
// Bracket access for the private field: this branch settles it synchronously, and the
1581+
// profile it hands the task is the one the context carried.
1582+
expect(task["taskApiConfigReadySettled"]).toBe(true)
1583+
await expect(task.getTaskApiConfigName()).resolves.toBe("parent-local-profile")
1584+
})
1585+
1586+
it("marks the api-config wait settled for a task reopened from a history entry", async () => {
1587+
// A reopened task carries whatever api config name was saved with it - including none,
1588+
// for entries written before the name was recorded - and its initialization is complete
1589+
// the moment the constructor returns. The dispose-time repair reads this flag, so a
1590+
// reopened task must not spend its life reading as a pending initialization.
1591+
const historyItem = {
1592+
id: "settled-history",
1593+
number: 2,
1594+
task: "history",
1595+
ts: Date.now(),
1596+
tokensIn: 0,
1597+
tokensOut: 0,
1598+
totalCost: 0,
1599+
}
1600+
const task = new Task({
1601+
provider: mockProvider,
1602+
apiConfiguration: mockApiConfig,
1603+
historyItem,
1604+
startTask: false,
1605+
})
1606+
1607+
expect(task["taskApiConfigReadySettled"]).toBe(true)
1608+
})
1609+
1610+
it("settles the api-config wait when the initialization rejects, and keeps the rejection reachable", async () => {
1611+
// The constructor observes settlement on a side channel rather than replacing the promise,
1612+
// so a rejection has to settle the flag too: otherwise a task whose lookup failed stays
1613+
// marked as still initializing forever, and the dispose-time repair keeps waiting on a
1614+
// promise that already failed. The rejection itself must still reach every other awaiter,
1615+
// which is why the observation is a then(f, r) beside the promise rather than a catch on it.
1616+
// The prototype is reached through unknown rather than any: only the private member this
1617+
// row replaces, and the swap is restored below.
1618+
const proto = Task.prototype as unknown as {
1619+
initializeTaskApiConfigName: () => Promise<void>
1620+
}
1621+
const initSpy = vi
1622+
.spyOn(proto, "initializeTaskApiConfigName")
1623+
.mockRejectedValueOnce(new Error("api config lookup refused"))
1624+
const task = new Task({
1625+
provider: mockProvider,
1626+
apiConfiguration: mockApiConfig,
1627+
task: "failed init",
1628+
startTask: false,
1629+
})
1630+
1631+
await expect(task["taskApiConfigReady"]).rejects.toThrow("api config lookup refused")
1632+
1633+
expect(task["taskApiConfigReadySettled"]).toBe(true)
1634+
initSpy.mockRestore()
1635+
})
15631636
it("keeps history-task initialization distinct from delegated-child initialization", async () => {
15641637
const captureTaskRestarted = vi.spyOn(TelemetryService.instance, "captureTaskRestarted")
15651638
const historyItem = {

‎src/core/task/__tests__/Task.teardown.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,33 @@ describe("Task teardown, dispose-time metadata repair and diff-view release", ()
558558
historySpy.mockRestore()
559559
})
560560

561+
it("clears the repair flag and reports success when the metadata stage writes the history entry", async () => {
562+
// The success side of the stage the row above covers the failure of. The history write
563+
// lands, the flag disposeOnce() keys off is cleared rather than left alone, and the stage
564+
// reports success for itself - deliberately separate from the message write's own result,
565+
// which saveClineMessages() reports on its own.
566+
const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000")
567+
fsReal.mkdirSync(taskDir, { recursive: true })
568+
const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockResolvedValue([])
569+
const task = new Task({
570+
provider: mockProvider,
571+
apiConfiguration: mockApiConfig,
572+
task: "test task",
573+
startTask: false,
574+
})
575+
// Bracket access for the private field: set the flag first so the assertion below proves
576+
// the stage CLEARED it rather than merely never having set it.
577+
await task["taskApiConfigReady"]
578+
task["pendingTaskMetadataRepair"] = true
579+
580+
const persisted = await task["persistTaskMetadata"]()
581+
582+
expect(persisted).toBe(true)
583+
expect(historySpy).toHaveBeenCalledTimes(1)
584+
expect(task["pendingTaskMetadataRepair"]).toBe(false)
585+
historySpy.mockRestore()
586+
})
587+
561588
it("finishes the synchronous teardown before the dispose-time metadata retry", async () => {
562589
// The retry has to sit after disposeOnce()'s synchronous section, not in front of it:
563590
// abortTaskOnce() awaits diffReversionPromise right after void dispose(), an abandoned

0 commit comments

Comments
 (0)