Skip to content

Commit 0d98e35

Browse files
authored
Merge branch 'main' into codex/fix-agent-host-stateful-marker
2 parents 739efad + b34a8ac commit 0d98e35

64 files changed

Lines changed: 1876 additions & 1116 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/launch/SKILL.md

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,42 @@ npx @playwright/cli -s=$PW_SESSION snapshot
179179

180180
If a target looks stale after relaunching, run `npx @playwright/cli -s=$PW_SESSION close`, attach again with `$CDP`, and re-check `tab-list`.
181181

182-
### Focusing the chat input (works on Code OSS, including the Agents window)
182+
### Focusing the chat input
183+
184+
Use the `playwrightScripts/focus-chat-input.ts` script in both the regular
185+
workbench and the Agents window. It performs the complete focus flow in one
186+
Playwright call:
187+
188+
1. If a visible chat input is already focused, it does nothing.
189+
2. If a visible chat input exists but is not focused, it focuses that input.
190+
3. Otherwise, it invokes the platform chat-focus chord, waits for the input,
191+
and focuses it only if the chord did not already do so.
192+
193+
The script detects the platform from the browser page, prefers the active
194+
Agents session, and excludes inline chat inputs. If the cloned profile has
195+
customized the default chord, it falls back to the surface-specific command
196+
through the Command Palette.
183197

184198
```bash
185-
# macOS
186-
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i
187-
# Linux / Windows
188-
npx @playwright/cli -s=$PW_SESSION press Control+Alt+i
199+
LAUNCH_DIR=<dir-of-this-SKILL.md>
200+
FOCUS_CHAT="$LAUNCH_DIR/playwrightScripts/focus-chat-input.ts"
201+
npx @playwright/cli -s=$PW_SESSION run-code --filename="$FOCUS_CHAT"
202+
```
203+
204+
```powershell
205+
$skillDir = '<dir-of-this-SKILL.md>'
206+
$focusChat = Join-Path $skillDir 'playwrightScripts\focus-chat-input.ts'
207+
npx @playwright/cli "-s=$pwSession" run-code "--filename=$focusChat"
189208
```
190209

210+
The script returns
211+
`{ focused, focusChanged, focusInvoked, shortcutInvoked, commandPaletteFallbackInvoked, selector }`.
212+
`focusChanged` reports whether this script invocation moved focus into Chat,
213+
while `focusInvoked` reports whether the script had to call `focus()` directly.
214+
Both are `false` when the chat input was already focused. If the script fails,
215+
take a fresh snapshot and resolve any blocking dialog or unavailable chat state
216+
before retrying.
217+
191218
### Typing into Monaco (chat input, editors)
192219

193220
`fill` and `type` **silently fail** on Code OSS — Monaco's `native-edit-context` element doesn't react to Playwright's default input pipeline. Use one of these alternatives:
@@ -196,11 +223,12 @@ npx @playwright/cli -s=$PW_SESSION press Control+Alt+i
196223

197224
```bash
198225
LAUNCH_DIR=<dir-of-this-SKILL.md> # the same dir that holds scripts/launch.sh
226+
FOCUS_CHAT="$LAUNCH_DIR/playwrightScripts/focus-chat-input.ts"
199227
PASTE="$LAUNCH_DIR/scripts/monaco-paste.sh"
200228
export PW_SESSION # helper reads this env var
201229

202230
# Send a prompt:
203-
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i # focus chat input
231+
npx @playwright/cli -s=$PW_SESSION run-code --filename="$FOCUS_CHAT"
204232
"$PASTE" 'Please run `pwd && ls` using your terminal tool.'
205233
npx @playwright/cli -s=$PW_SESSION press Enter
206234

@@ -223,7 +251,7 @@ npx @playwright/cli -s=$PW_SESSION press Control+Alt+i
223251
**Why a helper script and not just docs:** the inline recipe involves a multi-line `node -e` heredoc with embedded JS template literals, which is exactly the kind of code that gets miscopied. There are also three non-obvious correctness traps the helper handles internally:
224252
1. Monaco's `native-edit-context` doesn't react to `fill` or `type`, only to actual paste events (or per-key `press`).
225253
2. Monaco renders ASCII spaces as U+00A0 (NBSP) in the view-line DOM, so verification has to normalize before comparing.
226-
3. Monaco updates its DOM **asynchronously** after a paste event — a synchronous read-back inside the same `eval` returns stale state. The helper waits two `requestAnimationFrame` ticks before reading.
254+
3. Monaco updates its DOM **asynchronously** after a paste event — a synchronous read-back inside the same `eval` returns stale state. The helper polls rendered view lines across paint cycles until the pasted prefix appears or verification times out.
227255

228256
- **Per-key `press`** (universal but slow — each press is a separate CLI invocation with Node startup cost):
229257
```bash
@@ -234,31 +262,38 @@ npx @playwright/cli -s=$PW_SESSION press Control+Alt+i
234262

235263
- **Clipboard paste via `pbcopy`** (fast on macOS, **but `NSPasteboard` is system-wide so any concurrent shell that touches the pasteboard will collide**). Only use when nothing else on the machine is using the clipboard for the duration of the paste.
236264
```bash
265+
LAUNCH_DIR=<dir-of-this-SKILL.md>
266+
FOCUS_CHAT="$LAUNCH_DIR/playwrightScripts/focus-chat-input.ts"
237267
printf '%s' "Your prompt here" | pbcopy
238-
npx @playwright/cli -s=$PW_SESSION press Control+Meta+i
268+
npx @playwright/cli -s=$PW_SESSION run-code --filename="$FOCUS_CHAT"
239269
npx @playwright/cli -s=$PW_SESSION press Meta+v
240270
npx @playwright/cli -s=$PW_SESSION press Enter
241271
```
242272

243-
The focus shortcut should leave `document.activeElement` on VS Code's `native-edit-context` editing surface. That is a useful sanity check when key presses appear to do nothing.
244-
245273
### Parallel multi-instance pattern
246274

247275
Because the launch skill is built around isolation, the natural workload is **many agents on one machine, each driving their own Code OSS**. The pattern boils down to giving each agent a unique `PW_SESSION` and passing it everywhere:
248276

249277
```bash
278+
LAUNCH_DIR=<dir-of-this-SKILL.md>
279+
FOCUS_CHAT="$LAUNCH_DIR/playwrightScripts/focus-chat-input.ts"
280+
PASTE="$LAUNCH_DIR/scripts/monaco-paste.sh"
281+
export PW_SESSION
282+
250283
# In agent A's shell:
251284
PW_SESSION="agent-A-$$"
252285
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
253286
CDP=$(jq -r .cdpPort <<<"$INFO")
254287
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
288+
npx @playwright/cli -s=$PW_SESSION run-code --filename="$FOCUS_CHAT"
255289
"$PASTE" "prompt for A" # helper picks up $PW_SESSION
256290

257291
# In agent B's shell (running concurrently):
258292
PW_SESSION="agent-B-$$"
259293
INFO=$("$LAUNCH" --agents -- --use-mock-keychain | tail -n1)
260294
CDP=$(jq -r .cdpPort <<<"$INFO")
261295
npx @playwright/cli -s=$PW_SESSION attach --cdp=http://127.0.0.1:$CDP
296+
npx @playwright/cli -s=$PW_SESSION run-code --filename="$FOCUS_CHAT"
262297
"$PASTE" "prompt for B"
263298
```
264299

@@ -276,10 +311,11 @@ document.querySelectorAll('.interactive-input-editor .view-line')
276311

277312
// More useful checks in Agents.
278313
document.querySelectorAll('.view-line')
279-
document.activeElement?.className === 'native-edit-context'
314+
document.activeElement?.matches('.native-edit-context, textarea.inputarea')
280315
```
281316
282-
The `Control+Meta+i` / `Control+Alt+i` focus shortcut still works; only the DOM shape after focus differs.
317+
The focus script accounts for these DOM differences and prioritizes the active
318+
Agents session.
283319
284320
### Verifying and clearing chat text
285321
@@ -306,7 +342,9 @@ npx @playwright/cli -s=$PW_SESSION press Control+a
306342
npx @playwright/cli -s=$PW_SESSION press Backspace
307343
```
308344
309-
If the keyboard shortcut cannot focus chat because the surface is not available yet, take a snapshot and navigate the UI into a state where chat exists before retrying. Avoid treating completed CLI commands as proof that text was entered.
345+
If the focus script cannot reach Chat because the surface is not available yet,
346+
take a snapshot and navigate the UI into a state where chat exists before
347+
retrying. Avoid treating completed CLI commands as proof that text was entered.
310348
311349
### Screenshots (paper trail)
312350
@@ -392,5 +430,5 @@ Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up.
392430
- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run compile` (one-shot, also rebuilds all built-in extensions) or `npm run watch` (incremental). A common cause: you ran `npm run transpile-client` to satisfy unit tests, which populated `out/` but not `extensions/*/out/`, so preLaunch's "is `out/` missing?" check skipped the compile.
393431
- **`launch.sh` exits non-zero with a log tail** - either pre-launch failed, `code.sh` died before CDP came up, or CDP never opened within 90s. The tail printed to stderr is from `runDir/code.log` - read it to diagnose.
394432
- **Snapshot shows the wrong page or no expected controls** - use `tab-list`, switch with `tab-select <index>` if needed, then re-snapshot before interacting.
395-
- **CLI typing commands complete but the input stays empty** - focus chat with the platform shortcut, use `press` or clipboard paste rather than `fill` / `type`, then verify the input state before sending.
433+
- **CLI typing commands complete but the input stays empty** - run `playwrightScripts/focus-chat-input.ts`, use `press` or clipboard paste rather than `fill` / `type`, and verify the input state before sending.
396434
- **Auth missing in the launched window** - confirm the source profile is actually authed (`ls "$SOURCE_UDD"` should contain `User/`, and `ls "$SOURCE_UDD/User/globalStorage"` should show persisted extension state). **On Windows, check the shared-data-dir first**: the GitHub session blob lives in `%USERPROFILE%\.vscode-oss-shared\sharedStorage\state.vscdb`, not in the profile. The launcher logs `copying shared data: <src> -> <dst>` on stderr when it finds it, and warns `no shared-data-dir at <path>` when it doesn't. A missing or empty source shared-data-dir means signing in again against the source profile is what you need - see [Windows authentication](#windows-authentication).
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/* eslint-disable local/code-no-unused-expressions, @stylistic/semi -- playwright-cli requires a bare, unterminated function expression. */
7+
async page => {
8+
const selectors = [
9+
'.session-view.is-active .new-chat-input-area :is(.native-edit-context, textarea.inputarea)',
10+
'.session-view.is-active .sessions-chat-editor :is(.native-edit-context, textarea.inputarea)',
11+
'.session-view.is-active .interactive-session .chat-input-container :is(.native-edit-context, textarea.inputarea)',
12+
'.monaco-workbench .interactive-session .chat-input-container :is(.native-edit-context, textarea.inputarea)'
13+
];
14+
15+
const findVisibleChatInput = async () => {
16+
let firstVisible;
17+
for (const selector of selectors) {
18+
const candidates = page.locator(selector);
19+
for (let index = 0; index < await candidates.count(); index++) {
20+
const candidate = candidates.nth(index);
21+
const isExcluded = await candidate.evaluate(element => Boolean(element.closest('.inline-chat-widget, .automation-form-prompt-host')));
22+
if (await candidate.isVisible() && !isExcluded) {
23+
const match = { input: candidate, selector };
24+
if (await candidate.evaluate(element => document.activeElement === element)) {
25+
return match;
26+
}
27+
firstVisible ??= match;
28+
}
29+
}
30+
}
31+
return firstVisible;
32+
};
33+
34+
const isFocused = input => input.evaluate(element => document.activeElement === element);
35+
const focusIfNeeded = async input => {
36+
if (await isFocused(input)) {
37+
return false;
38+
}
39+
await input.focus();
40+
if (!await isFocused(input)) {
41+
throw new Error('Chat input did not retain focus');
42+
}
43+
return true;
44+
};
45+
const waitForVisibleChatInput = async attempts => {
46+
for (let attempt = 0; attempt < attempts; attempt++) {
47+
const match = await findVisibleChatInput();
48+
if (match) {
49+
return match;
50+
}
51+
await page.waitForTimeout(100);
52+
}
53+
return undefined;
54+
};
55+
56+
let match = await findVisibleChatInput();
57+
if (match) {
58+
const focusInvoked = await focusIfNeeded(match.input);
59+
return { focused: true, focusChanged: focusInvoked, focusInvoked, shortcutInvoked: false, commandPaletteFallbackInvoked: false, selector: match.selector };
60+
}
61+
62+
const platform = await page.evaluate(() => navigator.userAgentData?.platform ?? navigator.platform);
63+
const shortcut = /^mac/i.test(platform) ? 'Control+Meta+i' : 'Control+Alt+i';
64+
await page.keyboard.press(shortcut);
65+
match = await waitForVisibleChatInput(10);
66+
67+
let commandPaletteFallbackInvoked = false;
68+
if (!match) {
69+
commandPaletteFallbackInvoked = true;
70+
const isAgentsWindow = await page.locator('.agent-sessions-workbench').count() > 0;
71+
const commandId = isAgentsWindow ? 'sessions.focusActiveSession' : 'workbench.action.chat.open';
72+
await page.keyboard.press('F1');
73+
const commandPaletteInput = page.locator('.quick-input-widget .quick-input-box input');
74+
try {
75+
await commandPaletteInput.waitFor({ state: 'visible', timeout: 1000 });
76+
} catch {
77+
throw new Error('F1 did not open the Command Palette; check the cloned profile keybindings');
78+
}
79+
await commandPaletteInput.fill(`>${commandId}`);
80+
await page.keyboard.press('Enter');
81+
match = await waitForVisibleChatInput(50);
82+
}
83+
84+
if (!match) {
85+
throw new Error(`No visible chat input found after invoking ${shortcut} and the command palette fallback`);
86+
}
87+
88+
const focusInvoked = await focusIfNeeded(match.input);
89+
return { focused: true, focusChanged: true, focusInvoked, shortcutInvoked: true, commandPaletteFallbackInvoked, selector: match.selector };
90+
}

.agents/skills/launch/scripts/monaco-paste.sh

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,17 @@
2525
# Stderr: diagnostic noise from @playwright/cli (suppressed unless caller wants it).
2626
# Exit code:
2727
# 0 success
28-
# 1 paste verify failed, eval failed, or the page had no native-edit-context
28+
# 1 paste verify failed, eval failed, or the page had no chat input editing surface
2929
# 2 argument/usage error (empty input, missing tools)
3030
#
3131
# Required tools on PATH: npx (with @playwright/cli reachable), node, jq.
3232
#
3333
# Assumes:
3434
# - You have already run `npx @playwright/cli [-s=NAME] attach --cdp=http://127.0.0.1:$CDP`
3535
# in the same session this script reads (--session arg, $PW_SESSION env, or "default").
36-
# - The Agents window is open and a new-chat / chat view with a Monaco
37-
# editor is on screen. The script auto-focuses the first
38-
# `.new-chat-input-area .native-edit-context`, falling back to any
39-
# `.native-edit-context`.
36+
# - A chat view with a Monaco editor is on screen. The script preserves the
37+
# focused chat input, then falls back to the active Agents session or a
38+
# regular-workbench chat input.
4039

4140
set -u
4241
umask 077
@@ -64,6 +63,8 @@ done
6463
SESSION="${PW_SESSION_OVERRIDE:-${PW_SESSION:-}}"
6564
PW_ARGS=()
6665
[[ -n "$SESSION" ]] && PW_ARGS=("-s=$SESSION")
66+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
67+
FOCUS_CHAT="$SCRIPT_DIR/../playwrightScripts/focus-chat-input.ts"
6768

6869
# Text: prefer the positional arg; otherwise read all of stdin.
6970
# Stdin is preferred for arbitrary text because it avoids any shell
@@ -97,48 +98,76 @@ case "${OSTYPE:-$(uname -s)}" in
9798
*) SELECT_ALL_MOD="Control" ;;
9899
esac
99100

100-
# Step 1 (optional): clear the focused Monaco editor by select-all + delete.
101+
# Step 1: ensure the intended Chat input has focus before sending any keys.
102+
if ! npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} run-code --filename="$FOCUS_CHAT" >/dev/null 2>&1; then
103+
echo '{"ok":false,"error":"failed to focus a visible chat input"}'
104+
exit 1
105+
fi
106+
107+
# Step 2 (optional): clear the focused Monaco editor by select-all + delete.
101108
# Done via the CLI's `press` so the keys flow through Monaco's real key
102109
# handler. Stays inside the CDP connection — no system clipboard.
103110
if [[ "$APPEND" != "1" ]]; then
104111
npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} press "${SELECT_ALL_MOD}+a" >/dev/null 2>&1 || true
105112
npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} press Backspace >/dev/null 2>&1 || true
106113
fi
107114

108-
# Step 2: build the eval payload via node so JSON escaping is automatic.
109-
# The async IIFE waits two requestAnimationFrames after dispatch — Monaco
110-
# updates its view-line DOM asynchronously, so a same-tick read-back
111-
# returns stale state. Two rAFs = full paint cycle.
115+
# Step 3: build the eval payload via node so JSON escaping is automatic.
116+
# The async IIFE polls the rendered view lines after dispatch because Monaco
117+
# updates them asynchronously and Agents inputs can take longer than one paint.
112118
JS=$(node -e '
113119
const text = process.argv[1];
114120
const verify = process.argv[2] === "1";
115121
console.log(`(async () => {
116-
const root = document.querySelector(".new-chat-input-area .native-edit-context")
117-
|| document.querySelector(".sessions-chat-editor .native-edit-context")
118-
|| document.querySelector(".native-edit-context");
119-
if (!root) return JSON.stringify({ ok: false, error: "no native-edit-context found on page" });
122+
const selectors = [
123+
".session-view.is-active .new-chat-input-area :is(.native-edit-context, textarea.inputarea)",
124+
".session-view.is-active .sessions-chat-editor :is(.native-edit-context, textarea.inputarea)",
125+
".session-view.is-active .interactive-session .chat-input-container :is(.native-edit-context, textarea.inputarea)",
126+
".monaco-workbench .interactive-session .chat-input-container :is(.native-edit-context, textarea.inputarea)"
127+
];
128+
const isEligible = element => {
129+
if (!element?.matches?.(".native-edit-context, textarea.inputarea")) return false;
130+
if (!element.closest(".new-chat-input-area, .sessions-chat-editor, .interactive-session .chat-input-container")) return false;
131+
if (element.closest(".inline-chat-widget, .automation-form-prompt-host")) return false;
132+
const style = getComputedStyle(element);
133+
return element.isConnected
134+
&& element.getClientRects().length > 0
135+
&& style.display !== "none"
136+
&& style.visibility !== "hidden";
137+
};
138+
let root = isEligible(document.activeElement) ? document.activeElement : undefined;
139+
for (const selector of selectors) {
140+
root ||= Array.from(document.querySelectorAll(selector)).find(isEligible);
141+
}
142+
if (!root) return JSON.stringify({ ok: false, error: "no visible chat input editing surface found on page" });
120143
root.focus();
121144
const dt = new DataTransfer();
122145
dt.setData("text/plain", ${JSON.stringify(text)});
123146
root.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
124-
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
125147
const editor = root.closest(".monaco-editor");
126-
const viewLines = Array.from(editor.querySelectorAll(".view-line")).map(l => l.textContent);
127148
// Monaco renders regular ASCII spaces as U+00A0 (NBSP) in view-lines for
128149
// visual fidelity. Also, joining view-lines drops the logical newlines
129150
// between them. Normalize both sides before comparing.
130151
// (Note: \\u00A0 and \\r\\n are double-escaped because this string lives
131152
// inside a node template literal that would otherwise resolve them.)
132153
const norm = s => s.replace(/\\u00A0/g, " ").replace(/\\r?\\n/g, "");
133-
const joined = norm(viewLines.join(""));
134-
const actualLength = joined.length;
135154
const expectedFull = norm(${JSON.stringify(text)});
136155
const expectedPrefix = expectedFull.slice(0, Math.min(40, expectedFull.length));
137-
const prefixMatched = joined.startsWith(expectedPrefix) || joined.includes(expectedPrefix.slice(0, 20));
138156
const verifyEnabled = ${verify ? "true" : "false"};
157+
let viewLines = [];
158+
let joined = "";
159+
let prefixMatched = false;
160+
for (let attempt = 0; attempt < 20; attempt++) {
161+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
162+
viewLines = Array.from(editor.querySelectorAll(".view-line")).map(l => l.textContent);
163+
joined = norm(viewLines.join(""));
164+
prefixMatched = joined.startsWith(expectedPrefix) || joined.includes(expectedPrefix.slice(0, 20));
165+
if (!verifyEnabled || prefixMatched) break;
166+
await new Promise(r => setTimeout(r, 100));
167+
}
139168
return JSON.stringify({
140169
ok: !verifyEnabled || prefixMatched,
141-
actualLength,
170+
actualLength: joined.length,
142171
expectedLength: ${JSON.stringify(text)}.length,
143172
viewLineCount: viewLines.length,
144173
firstViewLine: (viewLines[0] || "").slice(0, 80),
@@ -147,7 +176,7 @@ JS=$(node -e '
147176
})()`);
148177
' "$TEXT" "$VERIFY")
149178

150-
# Step 3: run the eval. The CLI prints "### Result" then a JSON-encoded
179+
# Step 4: run the eval. The CLI prints "### Result" then a JSON-encoded
151180
# string on the next line, followed by "### Ran Playwright code" noise.
152181
RAW=$(npx @playwright/cli ${PW_ARGS[@]+"${PW_ARGS[@]}"} eval "$JS" 2>&1) || {
153182
echo "{\"ok\":false,\"error\":\"@playwright/cli eval failed\"}"

0 commit comments

Comments
 (0)