Skip to content

Commit a0f013f

Browse files
committed
🐛 fix(vscode): finalize unresolved tool activity
Stop tool cards from remaining or returning to running after tracking ends, while preserving partial output and authoritative persisted results.
1 parent a972079 commit a0f013f

10 files changed

Lines changed: 424 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Fixed
1818

19+
- Stop unresolved tool cards from remaining or returning to a running state after history restoration or session termination; preserve partial output and indicate that the final tool result was not recorded.
1920
- Prevent invalid project `enabledModels` values from falling through to global scope, resolve relative `PI_CODING_AGENT_DIR` paths from the session directory, and keep picker scope labels synchronized when scoped models disappear.
2021

2122
## [0.10.3] - 2026-08-06

apps/vscode/src/extension/conversation/ConversationItemStore.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ export class ConversationItemStore {
143143
return this.#toolItems.has(toolCallId);
144144
}
145145

146+
finalizeUnresolvedTools(): void {
147+
for (const owner of this.#toolItems.values()) {
148+
const { turnId, itemId } = owner.location;
149+
if (!turnId) continue;
150+
const item = this.turnItem(turnId, itemId);
151+
if (item?.type !== "tool" || item.tool.status !== "running") continue;
152+
this.upsertTurnItem(turnId, {
153+
...item,
154+
tool: { ...item.tool, status: "cancelled", isError: false },
155+
});
156+
}
157+
}
158+
146159
/** Detects ambiguous live adoption before any public item is mutated. */
147160
preflightPersistedOwnership(input: PersistedOwnershipPreflight): PlacementConflict | undefined {
148161
const assistantCounts = countByKey(input.assistantSources, (source) => source.correlationKey);
@@ -182,7 +195,11 @@ export class ConversationItemStore {
182195

183196
const existing = this.#persistedAssistants.get(source.entryId);
184197
if (existing) {
185-
this.#publishAssistant(existing, input.turnId, input.buildActivities(existing.viewMessageId));
198+
this.#publishAssistant(
199+
existing,
200+
input.turnId,
201+
this.#preserveToolExecutionState(input.buildActivities(existing.viewMessageId)),
202+
);
186203
return { kind: "placed", viewMessageId: existing.viewMessageId };
187204
}
188205

@@ -202,7 +219,11 @@ export class ConversationItemStore {
202219
addSetValue(this.#persistedAssistantKeys, source.correlationKey, source.entryId);
203220
if (liveOwner) this.#liveAssistants.set(source.correlationKey, owner);
204221
}
205-
this.#publishAssistant(owner, input.turnId, input.buildActivities(owner.viewMessageId));
222+
this.#publishAssistant(
223+
owner,
224+
input.turnId,
225+
this.#preserveToolExecutionState(input.buildActivities(owner.viewMessageId)),
226+
);
206227
return { kind: "placed", viewMessageId: owner.viewMessageId };
207228
}
208229

@@ -333,6 +354,25 @@ export class ConversationItemStore {
333354
return this.#items.flatMap((item) => item.type === "turn" ? item.items : [item]);
334355
}
335356

357+
#preserveToolExecutionState(activities: AgentActivityView[]): AgentActivityView[] {
358+
return activities.map((activity) => {
359+
if (activity.type !== "tool") return activity;
360+
const owner = this.#toolItems.get(activity.tool.id);
361+
const current = owner ? this.turnItem(owner.location.turnId, owner.location.itemId) : undefined;
362+
if (current?.type !== "tool") return activity;
363+
return {
364+
...activity,
365+
tool: {
366+
...activity.tool,
367+
status: current.tool.status,
368+
isError: current.tool.isError,
369+
...(current.tool.output !== undefined ? { output: current.tool.output } : {}),
370+
...(current.tool.endedAt !== undefined ? { endedAt: current.tool.endedAt } : {}),
371+
},
372+
};
373+
});
374+
}
375+
336376
#publishAssistant(owner: AssistantOwner, turnId: string, activities: AgentActivityView[]): void {
337377
const staysInTurn = owner.locations.length > 0
338378
&& owner.locations.every((location) => location.turnId === turnId);

apps/vscode/src/extension/conversation/ConversationProjection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class ConversationProjection {
9090

9191
this.#projectEntries(entries, branchEdges);
9292
this.#completePersistedTurn(true);
93+
this.#store.finalizeUnresolvedTools();
9394
this.#touch();
9495
}
9596

@@ -194,6 +195,11 @@ export class ConversationProjection {
194195
return true;
195196
}
196197

198+
finalizeUnresolvedTools(): void {
199+
this.#store.finalizeUnresolvedTools();
200+
this.#touch();
201+
}
202+
197203
applyEvent(event: RpcEvent): void {
198204
switch (event.type) {
199205
case "agent_start":
@@ -416,6 +422,7 @@ export class ConversationProjection {
416422
}
417423

418424
#settleAgentTurn(): void {
425+
this.#store.finalizeUnresolvedTools();
419426
if (this.#activeTurnId) {
420427
const turn = this.#turn(this.#activeTurnId);
421428
if (turn.status === "running") {

apps/vscode/src/extension/conversation/conversation-projection.SPEC.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The Host emits one ordered `conversationItems` collection. A visual turn has its
2323
- A user `message` opens a visual turn and supplies its stable `sourceEntryId`.
2424
- Assistant content becomes reasoning, response, and tool activities in protocol order. The persisted assistant identity is the session entry ID; a Pi message ID or timestamp is only a live-to-persisted correlation clue and never merges two persisted entries.
2525
- A tool result updates the activity identified by `toolCallId`; it is not a second visible activity. Assistant takeover relocates all reasoning, response, and embedded tool-call parts as one ownership unit, including the tool location. Once a persisted tool result arrives, delayed live tool events cannot replace its stable content.
26+
- A complete history replacement finalizes every tool still marked `running` as `cancelled`: FrostPi no longer has live tracking and did not receive a final result. This local status preserves partial output and may be replaced by a later persisted tool result.
2627
- `compaction`, `branch_summary`, and `custom_message` are independent items at their active-path positions.
2728
- Compaction never removes or hides earlier active-path items. Nested `retainedTail` values are LLM-context metadata and are not expanded into transcript items.
2829
- Every `custom_message` with `display: true` renders generic text and image blocks. `display: false` messages and plain `custom` state entries are omitted.
@@ -46,7 +47,7 @@ A persisted user message closes the preceding visual turn and opens a user-ancho
4647

4748
Live `message_end(error)` displays the error activity but leaves the turn running until `agent_end` decides whether Pi will retry. `agent_end(willRetry: true)` keeps the running turn and `auto_retry_start` adds a notice; `willRetry: false` commits the pending error. This uses the existing turn statuses and does not persist retry notices.
4849

49-
While an agent run is active, queued follow-ups remain outside persisted conversation order. Pi may emit a follow-up user message without another `agent_start`; promotion follows protocol FIFO order and closes the prior visual turn. Abort, process stop, and process failure clear the local queue.
50+
While an agent run is active, queued follow-ups remain outside persisted conversation order. Pi may emit a follow-up user message without another `agent_start`; promotion follows protocol FIFO order and closes the prior visual turn. Abort, process stop, and process failure clear the local queue. `agent_settled`, process stop, and process failure also finalize unresolved running tools as `cancelled`; they do not infer a tool error or alter the turn status.
5051

5152
Live activity updates replace existing view objects instead of mutating them. This is required for bridge deltas and Webview-owned disclosure state. Documented assistant events provide an ID or timestamp correlation clue; a malformed live assistant without either is omitted until persisted refresh rather than emitted as an uncorrelatable duplicate. Notices emitted during an active turn remain inside its ordered items; idle notices are top-level conversation items.
5253

apps/vscode/src/extension/sessions/SessionRuntime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export class SessionRuntime {
151151

152152
async stop(): Promise<void> {
153153
this.#lifecycleVersion += 1;
154+
this.#conversation.finalizeUnresolvedTools();
154155
this.#viewState.setStatus("stopping");
155156
this.#stopLiveStatsRefresh();
156157
this.#notifyChange();
@@ -595,6 +596,7 @@ export class SessionRuntime {
595596
connection.onFailure((error) => {
596597
this.#logger.error(`Session ${this.id} failed`, error);
597598
this.#stopLiveStatsRefresh();
599+
this.#conversation.finalizeUnresolvedTools();
598600
this.#conversation.clearQueuedFollowUps();
599601
this.#viewState.setStatus("failed", errorMessage(error));
600602
this.#notifyChange();

apps/vscode/src/webview/features/conversation/ToolActivity.svelte

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,24 @@
99
1010
const tool = $derived(activity.tool);
1111
const icon = $derived(toolIcon(tool.name));
12-
const statusIcon = $derived(tool.status === "running" ? "loading codicon-modifier-spin" : tool.status === "error" ? "error" : "check");
12+
const statusIcon = $derived(
13+
tool.status === "running"
14+
? "loading codicon-modifier-spin"
15+
: tool.status === "error"
16+
? "error"
17+
: tool.status === "cancelled"
18+
? "warning"
19+
: "check",
20+
);
21+
const statusLabel = $derived(
22+
tool.status === "cancelled"
23+
? "Final tool result was not received; execution may have been interrupted."
24+
: tool.status === "running"
25+
? "Tool is running"
26+
: tool.status === "error"
27+
? "Tool failed"
28+
: "Tool completed",
29+
);
1330
const errorSummary = $derived(tool.status === "error" ? firstLine(tool.output) : "");
1431
</script>
1532

@@ -19,7 +36,11 @@
1936
<span class="tool-activity-name">{tool.name}</span>
2037
<span class="tool-activity-label" title={tool.label}>{tool.label}</span>
2138
{#if errorSummary}<span class="tool-error-summary" title={tool.output}>{errorSummary}</span>{/if}
22-
<span class={`codicon codicon-${statusIcon} activity-status`} aria-hidden="true"></span>
39+
<span
40+
class={`codicon codicon-${statusIcon} activity-status${tool.status === "cancelled" ? " tool-status-cancelled" : ""}`}
41+
title={statusLabel}
42+
aria-label={statusLabel}
43+
></span>
2344
<span class={`codicon codicon-chevron-${open ? "down" : "right"} activity-chevron`} aria-hidden="true"></span>
2445
</Collapsible.Trigger>
2546
<Collapsible.Content class="activity-content tool-activity-content">
@@ -160,6 +181,7 @@
160181
color: var(--frost-muted);
161182
font: 10.5px/1.35 var(--font-mono);
162183
}
184+
.tool-status-cancelled { color: var(--frost-warning); }
163185
.tool-error-summary {
164186
min-width: 0;
165187
max-width: 38%;

apps/vscode/test/unit/ConversationProjection.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33

44
import { ConversationProjection } from "../../src/extension/conversation/ConversationProjection.js";
55
import type { AgentTurnView } from "../../src/shared/model/conversationModel.js";
6+
import type { ToolCallView } from "../../src/shared/model/toolCallModel.js";
67

78
describe("ConversationProjection", () => {
89
it("preserves active-path order across turns, branch edges, boundaries, and custom blocks", () => {
@@ -375,6 +376,79 @@ describe("ConversationProjection", () => {
375376
expect(normalizePersistedProjection(projection)).toEqual(normalizePersistedProjection(replacement));
376377
});
377378

379+
it("finalizes unresolved tools after complete history replacement without inventing a result", () => {
380+
const projection = new ConversationProjection();
381+
projection.replaceEntries([
382+
userEntry("u1", null, "Inspect", 1),
383+
assistantEntry("a1", "u1", [{ type: "toolCall", id: "t1", name: "read", arguments: { path: "a.ts" } }], "toolUse", 2),
384+
], []);
385+
386+
const [tool] = turns(projection.read().items)[0]?.items.filter((item) => item.type === "tool") ?? [];
387+
expect(tool?.tool).toMatchObject({
388+
id: "t1",
389+
status: "cancelled",
390+
args: { path: "a.ts" },
391+
isError: false,
392+
});
393+
expect(tool?.tool.output).toBeUndefined();
394+
expect(tool?.tool.endedAt).toBeUndefined();
395+
});
396+
397+
it("preserves settled tool state through assistant takeover and accepts a later authoritative result", () => {
398+
const projection = new ConversationProjection();
399+
projection.appendUserPrompt("Inspect", [], 1);
400+
projection.applyEvent({ type: "agent_start" });
401+
projection.applyEvent({ type: "message_start", message: { role: "user", content: "Inspect", timestamp: 1 } });
402+
projection.applyEvent({
403+
type: "message_end",
404+
message: {
405+
id: "assistant-1",
406+
role: "assistant",
407+
content: [{ type: "toolCall", id: "t1", name: "read", arguments: { path: "a.ts" } }],
408+
stopReason: "toolUse",
409+
timestamp: 2,
410+
},
411+
});
412+
projection.applyEvent({
413+
type: "tool_execution_update",
414+
toolCallId: "t1",
415+
toolName: "read",
416+
args: { path: "a.ts" },
417+
partialResult: [{ type: "text", text: "partial" }],
418+
});
419+
projection.applyEvent({ type: "agent_settled" });
420+
expect(projectedTool(projection, "t1")).toMatchObject({ status: "cancelled", output: "partial", isError: false });
421+
422+
expect(projection.reconcileEntries([
423+
userEntry("u1", null, "Inspect", 1),
424+
assistantEntry(
425+
"a1",
426+
"u1",
427+
[{ type: "toolCall", id: "t1", name: "read", arguments: { path: "a.ts" } }],
428+
"toolUse",
429+
2,
430+
"assistant-1",
431+
),
432+
], [])).toBe("applied");
433+
expect(projectedTool(projection, "t1")).toMatchObject({ status: "cancelled", output: "partial", isError: false });
434+
435+
expect(projection.reconcileEntries([
436+
toolResultEntry("r1", "a1", "t1", "final", 3),
437+
], [])).toBe("applied");
438+
expect(projectedTool(projection, "t1")).toMatchObject({ status: "complete", output: "final", isError: false, endedAt: 3 });
439+
});
440+
441+
it("keeps persisted tool failures authoritative", () => {
442+
const projection = new ConversationProjection();
443+
projection.replaceEntries([
444+
userEntry("u1", null, "Inspect", 1),
445+
assistantEntry("a1", "u1", [{ type: "toolCall", id: "t1", name: "read", arguments: {} }], "toolUse", 2),
446+
toolResultEntry("r1", "a1", "t1", "failed", 3, true),
447+
], []);
448+
449+
expect(projectedTool(projection, "t1")).toMatchObject({ status: "error", output: "failed", isError: true, endedAt: 3 });
450+
});
451+
378452
it("ignores stale assistant and compaction replay after persisted replacement", () => {
379453
const projection = new ConversationProjection();
380454
projection.replaceEntries([
@@ -532,6 +606,15 @@ describe("ConversationProjection", () => {
532606
});
533607
});
534608

609+
function projectedTool(projection: ConversationProjection, toolCallId: string): ToolCallView | undefined {
610+
for (const turn of turns(projection.read().items)) {
611+
for (const item of turn.items) {
612+
if (item.type === "tool" && item.tool.id === toolCallId) return item.tool;
613+
}
614+
}
615+
return undefined;
616+
}
617+
535618
function responseText(item: Extract<AgentTurnView["items"][number], { type: "response" }>): string {
536619
return item.blocks.map((block) => block.type === "text" || block.type === "error" ? block.text : "").join("");
537620
}
@@ -626,13 +709,14 @@ function toolResultEntry(
626709
toolCallId: string,
627710
output: string,
628711
timestamp: number,
712+
isError = false,
629713
): RpcSessionEntry {
630714
return {
631715
type: "message",
632716
id,
633717
parentId,
634718
timestamp,
635-
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text: output }], timestamp },
719+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text: output }], isError, timestamp },
636720
};
637721
}
638722

0 commit comments

Comments
 (0)