Skip to content

Commit 5bd1a52

Browse files
authored
fix(playground): saved-replay sidebar + chat resilience (v0.2.3) (#43)
## Summary Five interlocking playground bugs surfaced during a single user session — each was masking the next. Fixes them all in one PR; cuts v0.2.3. - **Saved-replay sidebar ignored manual deletes.** `vscode.workspace.createFileSystemWatcher` is unreliable for dot-prefixed paths (notably `.copilotkit/`) on Windows. Replaced with a Node `fs.watch`-based `FixturesDirWatcher` that retries until the dir exists. Output channel now logs `[fixtures-watcher] attached`, `fs.watch <event>`, and `[playground] fixtures dir changed — refreshing sidebar` so the chain is self-diagnosing. - **Clicking ▶ on a saved replay produced an empty chat.** The extension posted `bundle-ready` then `play-fixture` back-to-back; the webview's `executePlaygroundBundle` is async, so the `play-fixture` `CustomEvent` fired before the new bundle's `PlaygroundChat` had registered its replay listener. App shell now unmounts the stale bundle synchronously on `bundle-ready`, queues the messages in state, and dispatches in a parent `useEffect` keyed on `[bundle, pendingReplay]` (React runs child effects before parent effects on the same commit → listener is attached before dispatch). - **Clicking ▶ on a deleted fixture permanently bricked the panel.** `load-fixture` set `replayFixturePath` first, then tried to read. The read failed but the path stayed, so every subsequent `runBundle` re-read the missing file and crashed. Refresh did nothing. Now: read-then-commit; on read failure clear state, refresh the sidebar, warn. `runBundle` also wraps its fixture read in try/catch and falls back to record mode when the file vanishes mid-session. - **Refresh wasn't a recovery path.** `runBundle` now posts a fresh `fixtures-list` at the *start* of every bundle pass, so Refresh is a guaranteed manual reconciliation with disk. - **Chat returned silently with too many `vscode.lm` tools.** Claude through Copilot Chat returns 200 OK + empty stream when ~80+ tools are forwarded. `vscode-lm-factory` now auto-retries once without `vscode.lm` tools, so the user gets a real response on the same query without changing their settings. If both attempts return empty, an actionable in-chat message explains the cause and points at `copilotkit.playground.enableVscodeLmTools`. - **`LanguageModelDataPart` text/json mimes are now surfaced.** Newer Claude builds routed through Copilot Chat stream text content as `DataPart` with `text/plain` or `application/json` mimes — previously dropped on the floor, producing the same empty-chat symptom. Unknown part types are logged with their constructor name + mime. Plus user-driven removal of a stale frontend tool from `test-workspace/playground/v2/Tools.tsx`. ## Test plan - [x] `pnpm run check-types` clean - [x] `pnpm run test` — 312 pass, 1 skipped (added 8 new tests across `view-provider`, `vscode-lm-factory`, `fixtures-dir-watcher`, `App.replay-race`) - [x] Manual: deleted a fixture from disk → sidebar updates within ~1s - [x] Manual: clicked ▶ on a deleted fixture → toast, sidebar refreshes, panel does not hang - [x] Manual: asked "what's the weather in sarajevo" with `enableVscodeLmTools=true` and 87 vscode.lm tools forwarded → auto-retry path fires, chat responds normally
2 parents a236e7c + 4aed415 commit 5bd1a52

12 files changed

Lines changed: 925 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
## [Unreleased]
44

5+
## 0.2.3 — 2026-05-14
6+
7+
### Fixed
8+
9+
- Playground saved-replay sidebar: deleting a fixture file out of band (Explorer, terminal, git pull) now refreshes the sidebar within ~1 s via a Node `fs.watch` on `.copilotkit/fixtures/`. The previous `vscode.workspace.createFileSystemWatcher` was unreliable for dot-prefixed paths on Windows; the entry would stick around until the next reload.
10+
- Playground ▶ on a saved replay no longer drops the recorded conversation. The previous race posted `play-fixture` before the new bundle's `PlaygroundChat` had registered its replay listener; the App shell now unmounts the stale bundle synchronously on `bundle-ready`, queues the replay messages, and dispatches them after the new chat mounts (React runs child effects before parent effects on the same commit, so the listener is guaranteed to be attached).
11+
- Playground recovery: clicking ▶ on a fixture whose file has been deleted no longer permanently bricks the panel ("Preparing chat surface…" + ENOENT loop). `load-fixture` now reads the file before committing `replayFixturePath`, refreshes the sidebar, and surfaces a one-shot warning. The `runBundle` pass also recovers when the active fixture vanishes mid-session by falling back to record mode instead of crashing.
12+
- Playground Refresh button: always reconciles the saved-replays sidebar with disk at the start of every rebundle. Refresh is now a guaranteed manual recovery path even if the fs.watch misses an event.
13+
- Playground chat with many `vscode.lm` tools: some models (notably Claude through Copilot Chat with ~80+ tools forwarded) silently reject requests by returning 200 OK with an empty stream. The chat now auto-retries once without the `vscode.lm` tools so the user gets a real response on the same query. If both attempts return empty, an actionable in-chat message explains the cause and points at the `copilotkit.playground.enableVscodeLmTools` setting.
14+
- Playground stream translation: text content streamed as `LanguageModelDataPart` (text/plain or application/json mimes) is surfaced as `TEXT_MESSAGE_CONTENT` instead of being silently dropped. Newer Claude builds routed through Copilot Chat had been producing empty chats this way.
15+
16+
### Added
17+
18+
- Playground output channel now logs unknown `vscode.lm` stream-part types (constructor name + mime when applicable) so future model-side changes leave a breadcrumb instead of an empty chat.
19+
520
## 0.2.2 — 2026-05-05
621

722
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "copilotkit-vscode-extension",
33
"displayName": "CopilotKit",
4-
"version": "0.2.2",
4+
"version": "0.2.3",
55
"description": "Preview generative-UI components, explore CopilotKit hooks, and inspect AG-UI agent runs — all without leaving your editor.",
66
"categories": [
77
"Other",

src/extension/activate.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "./playground/view-provider";
2222
import { scanPlayground } from "./playground/scanner";
2323
import { PlaygroundFileWatcher } from "./playground/file-watcher";
24+
import { FixturesDirWatcher } from "./playground/fixtures-dir-watcher";
2425

2526
let activePlaygroundProvider: PlaygroundViewProvider | null = null;
2627

@@ -343,6 +344,26 @@ export function activate(context: vscode.ExtensionContext): void {
343344
runPlaygroundScan();
344345
});
345346
context.subscriptions.push(playgroundFileWatcher);
347+
348+
// Reflect manual fixture changes (Explorer delete, git pull, etc.)
349+
// in the saved-replays sidebar. We use a Node fs.watch on the
350+
// fixtures directory rather than vscode.createFileSystemWatcher —
351+
// the latter is unreliable for dot-prefixed paths like
352+
// `.copilotkit/` on Windows. The webview-initiated save/delete
353+
// round-trip already pushes a fresh list, so this watcher is for
354+
// out-of-band edits only.
355+
const fixturesDir = path.join(workspaceRoot, ".copilotkit", "fixtures");
356+
const fixturesDirWatcher = new FixturesDirWatcher(
357+
fixturesDir,
358+
() => {
359+
playgroundOutputChannel.appendLine(
360+
"[playground] fixtures dir changed — refreshing sidebar",
361+
);
362+
playgroundProvider.refreshFixturesList();
363+
},
364+
(line) => playgroundOutputChannel.appendLine(line),
365+
);
366+
context.subscriptions.push(fixturesDirWatcher);
346367
}
347368

348369
runPlaygroundScan();
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
vi.mock("vscode", () => ({}));
7+
8+
import { FixturesDirWatcher } from "../fixtures-dir-watcher";
9+
10+
describe("FixturesDirWatcher", () => {
11+
let tmpRoot: string;
12+
let watcher: FixturesDirWatcher | null = null;
13+
14+
beforeEach(() => {
15+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fixtures-watcher-"));
16+
});
17+
18+
afterEach(() => {
19+
watcher?.dispose();
20+
watcher = null;
21+
fs.rmSync(tmpRoot, { recursive: true, force: true });
22+
});
23+
24+
it("fires onChange when a file inside the watched dir is deleted", async () => {
25+
const fixturesDir = path.join(tmpRoot, ".copilotkit", "fixtures");
26+
fs.mkdirSync(fixturesDir, { recursive: true });
27+
const filePath = path.join(fixturesDir, "x.json");
28+
fs.writeFileSync(filePath, "{}");
29+
30+
const onChange = vi.fn();
31+
watcher = new FixturesDirWatcher(fixturesDir, onChange);
32+
33+
fs.unlinkSync(filePath);
34+
35+
await waitFor(() => onChange.mock.calls.length > 0);
36+
expect(onChange).toHaveBeenCalled();
37+
});
38+
39+
it("fires onChange when a file is created in the watched dir", async () => {
40+
const fixturesDir = path.join(tmpRoot, ".copilotkit", "fixtures");
41+
fs.mkdirSync(fixturesDir, { recursive: true });
42+
43+
const onChange = vi.fn();
44+
watcher = new FixturesDirWatcher(fixturesDir, onChange);
45+
46+
fs.writeFileSync(path.join(fixturesDir, "new.json"), "{}");
47+
48+
await waitFor(() => onChange.mock.calls.length > 0);
49+
expect(onChange).toHaveBeenCalled();
50+
});
51+
52+
it("is a no-op when the directory does not exist yet", () => {
53+
const fixturesDir = path.join(tmpRoot, "absent", "fixtures");
54+
const log = vi.fn();
55+
const onChange = vi.fn();
56+
watcher = new FixturesDirWatcher(fixturesDir, onChange, log);
57+
58+
// Nothing thrown, no attachment log line.
59+
expect(log).not.toHaveBeenCalledWith(
60+
expect.stringMatching(/attached/),
61+
);
62+
expect(onChange).not.toHaveBeenCalled();
63+
});
64+
65+
it("dispose stops further events", async () => {
66+
const fixturesDir = path.join(tmpRoot, ".copilotkit", "fixtures");
67+
fs.mkdirSync(fixturesDir, { recursive: true });
68+
69+
const onChange = vi.fn();
70+
watcher = new FixturesDirWatcher(fixturesDir, onChange);
71+
watcher.dispose();
72+
watcher = null;
73+
74+
fs.writeFileSync(path.join(fixturesDir, "after-dispose.json"), "{}");
75+
// Give the OS a moment to deliver any stray events.
76+
await new Promise((r) => setTimeout(r, 100));
77+
expect(onChange).not.toHaveBeenCalled();
78+
});
79+
});
80+
81+
async function waitFor(
82+
predicate: () => boolean,
83+
{ timeoutMs = 2000, intervalMs = 20 } = {},
84+
): Promise<void> {
85+
const start = Date.now();
86+
while (!predicate()) {
87+
if (Date.now() - start > timeoutMs) {
88+
throw new Error("waitFor timeout");
89+
}
90+
await new Promise((r) => setTimeout(r, intervalMs));
91+
}
92+
}

0 commit comments

Comments
 (0)