363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import { clickButton, denFetch, waitFor } from "@openwork/behaviors";
|
|
import { connect, debuggerUrlFor, evaluate, listTargets } from "@openwork/cdp";
|
|
import { provider } from "../ctx.ts";
|
|
import { inPage } from "../inpage.ts";
|
|
import { DOCS_APP, DOCS_MEMBER, DOCS_PROMPT_CARDS, org } from "../seed.ts";
|
|
import { desktop } from "../surfaces.ts";
|
|
import type { DesktopShotSurface } from "../surfaces.ts";
|
|
import { dismissOverlays, fillForm, keepExpanded } from "../steps.ts";
|
|
import { startModelWitness } from "../witness.ts";
|
|
import type { ScriptedChunk } from "../witness.ts";
|
|
import { shot } from "./shot.ts";
|
|
|
|
const WITNESS_PROVIDER_ID = "docs-shots-provider";
|
|
const WITNESS_MODEL_ID = "docs-shots-model";
|
|
const CHAT_PLUGIN_NAME = "Call Prep";
|
|
const CHAT_SKILL_NAME = "call-prep";
|
|
const CHAT_SKILL_DESCRIPTION = "Prepare a call brief whenever you ask to prep a call.";
|
|
const CHAT_CLOSING_REPLY = "The call-prep skill is saved to your Library and ready to use.";
|
|
const resourceUri = "ui://openwork/skill-created/v1/view.html";
|
|
const composerMessage = "Turn what we just did into a reusable skill for me";
|
|
const ORGANIZATION_PROMPT_INTRO = "Try one of your organization's prompts:";
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function projectedCreateSkillTool(payload: Record<string, unknown>): string | null {
|
|
if (!Array.isArray(payload.tools)) return null;
|
|
for (const tool of payload.tools) {
|
|
if (!isRecord(tool) || !isRecord(tool.function)) continue;
|
|
const name = tool.function.name;
|
|
if (typeof name === "string" && name.endsWith("_create_skill")) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function completedToolCount(payload: Record<string, unknown>): number {
|
|
return Array.isArray(payload.messages)
|
|
? payload.messages.filter((message) => isRecord(message) && message.role === "tool").length
|
|
: 0;
|
|
}
|
|
|
|
function chatSceneScript(payload: Record<string, unknown>): readonly ScriptedChunk[] {
|
|
const toolName = completedToolCount(payload) === 0 ? projectedCreateSkillTool(payload) : null;
|
|
if (!toolName) {
|
|
return [
|
|
{ delta: { role: "assistant" } },
|
|
{ delta: { content: CHAT_CLOSING_REPLY } },
|
|
{ delta: {}, finishReason: "stop" },
|
|
];
|
|
}
|
|
return [
|
|
{ delta: { role: "assistant" } },
|
|
{
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: "call_create_call_prep",
|
|
type: "function",
|
|
function: {
|
|
name: toolName,
|
|
arguments: JSON.stringify({
|
|
pluginName: CHAT_PLUGIN_NAME,
|
|
skillMarkdown: [
|
|
"---",
|
|
`name: ${CHAT_SKILL_NAME}`,
|
|
`description: ${CHAT_SKILL_DESCRIPTION}`,
|
|
"---",
|
|
"",
|
|
"Whenever the user asks to prep a call, build a one-page brief with goals, context, and questions.",
|
|
].join("\n"),
|
|
}),
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
{ delta: {}, finishReason: "tool_calls" },
|
|
];
|
|
}
|
|
|
|
const model = provider(async (ctx) => {
|
|
const witness = await startModelWitness({
|
|
providerId: WITNESS_PROVIDER_ID,
|
|
modelId: WITNESS_MODEL_ID,
|
|
script: chatSceneScript,
|
|
});
|
|
ctx.onDispose(witness.close);
|
|
return witness;
|
|
});
|
|
|
|
const app = desktop({
|
|
org,
|
|
app: DOCS_APP,
|
|
model,
|
|
});
|
|
|
|
async function openEmptyTeamPromptSession(surface: DesktopShotSurface): Promise<void> {
|
|
const member = surface.organization.den.members[DOCS_MEMBER];
|
|
if (!member) throw new Error("The docs member was not provisioned.");
|
|
const config = await denFetch(member, "/v1/me/desktop-config", {
|
|
headers: {
|
|
authorization: `Bearer ${member.token}`,
|
|
"x-openwork-org-id": surface.organization.orgId,
|
|
},
|
|
});
|
|
const expectedPrompts = DOCS_PROMPT_CARDS.map((card) => card.prompt);
|
|
const expectedTitles = DOCS_PROMPT_CARDS.map((card) => card.title);
|
|
const actualPrompts = isRecord(config.body) && Array.isArray(config.body.onboardingPrompts)
|
|
? config.body.onboardingPrompts
|
|
: [];
|
|
const actualTitles = isRecord(config.body) && Array.isArray(config.body.onboardingPromptDescriptions)
|
|
? config.body.onboardingPromptDescriptions
|
|
: [];
|
|
if (!config.response.ok
|
|
|| JSON.stringify(actualPrompts) !== JSON.stringify(expectedPrompts)
|
|
|| JSON.stringify(actualTitles) !== JSON.stringify(expectedTitles)) {
|
|
throw new Error(`The docs member did not receive the seeded prompt policy: HTTP ${config.response.status} ${config.text.slice(0, 600)}`);
|
|
}
|
|
const titlesVisible = expectedTitles
|
|
.map((title) => `document.body.innerText.includes(${JSON.stringify(title)})`)
|
|
.join(" && ");
|
|
await waitFor(surface, titlesVisible, {
|
|
timeoutMs: 60_000,
|
|
label: "organization prompt config settled",
|
|
});
|
|
const task = await inPage(surface, `async () => {
|
|
const deadline = Date.now() + 60000;
|
|
let last = null;
|
|
while (Date.now() < deadline) {
|
|
last = await window.__openworkControl.execute("session.create_task", null);
|
|
if (last?.ok === true) return last;
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
return last;
|
|
}`, {}, { awaitPromise: true, timeoutMs: 70_000 });
|
|
if (!isRecord(task) || task.ok !== true) throw new Error(`Creating an empty prompt-card session failed: ${JSON.stringify(task)}`);
|
|
await waitFor(surface, `document.body.innerText.includes(${JSON.stringify(ORGANIZATION_PROMPT_INTRO)}) && ${titlesVisible}`, {
|
|
timeoutMs: 60_000,
|
|
label: "member-facing organization prompt cards",
|
|
});
|
|
await inPage(surface, `() => {
|
|
const label = [...document.querySelectorAll("span")]
|
|
.find((element) => (element.textContent ?? "").trim() === "Notifications");
|
|
const item = label?.closest("a, button");
|
|
const badge = item && [...item.querySelectorAll("span")]
|
|
.find((element) => (element.textContent ?? "").trim() === "1");
|
|
if (badge instanceof HTMLElement) badge.style.display = "none";
|
|
const account = document.querySelector('[data-testid="account-status-menu"]');
|
|
const status = account && [...account.children]
|
|
.find((element) => element instanceof HTMLElement && element.classList.contains("size-4"));
|
|
if (status instanceof HTMLElement) status.style.display = "none";
|
|
return true;
|
|
}`, {});
|
|
}
|
|
|
|
export const desktopTeamPromptCards = shot("desktop-team-prompt-cards", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}`,
|
|
steps: [dismissOverlays, openEmptyTeamPromptSession],
|
|
expect: [
|
|
ORGANIZATION_PROMPT_INTRO,
|
|
...DOCS_PROMPT_CARDS.map((card) => card.title),
|
|
],
|
|
never: ["What do you need done?", "Opening session…", "Summarize my week", "Connect a model provider"],
|
|
out: "packages/docs/images/desktop-team-prompt-cards.png",
|
|
});
|
|
|
|
const skillForm = fillForm({
|
|
'input[placeholder="e.g. customer-research"]': "call-brief",
|
|
'input[placeholder="When should an agent use this skill?"]': "Prepare a one-page brief before a customer call.",
|
|
'textarea[placeholder^="# Instructions"]': "# Instructions\n\n1. Pull the account's recent activity.\n2. Summarize the goal of the call in two sentences.\n3. List the three questions to ask.",
|
|
});
|
|
|
|
const showAdvancedSettings = keepExpanded("Advanced settings", "Add workspace MCP");
|
|
|
|
async function scrollAdvancedSettingsIntoView(surface: DesktopShotSurface): Promise<void> {
|
|
await inPage(surface, `() => {
|
|
document.querySelector("[data-inventory-group]")?.scrollIntoView({ block: "start" });
|
|
const toggle = [...document.querySelectorAll("button")]
|
|
.find((button) => (button.textContent ?? "").includes("Advanced settings"));
|
|
toggle?.scrollIntoView({ block: "center" });
|
|
return true;
|
|
}`, {});
|
|
}
|
|
|
|
export const librarySkills = shot("library-skills", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/extensions/skills`,
|
|
steps: [dismissOverlays],
|
|
expect: ["Library", "Add skill", "customer-research"],
|
|
never: ["Your library is empty."],
|
|
route: /\/extensions\/skills$/,
|
|
out: "packages/docs/images/library-skills-add-skill.png",
|
|
});
|
|
|
|
export const libraryCreateSkillModal = shot("library-create-skill-modal", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/extensions/skills`,
|
|
steps: [dismissOverlays, (surface) => clickButton(surface, "Add skill", { timeoutMs: 120_000 }), skillForm],
|
|
expect: ["Create a skill", "Name", "Description", "Create skill"],
|
|
never: ["Sign in to OpenWork Cloud"],
|
|
viewport: { width: 1440, height: 1000, deviceScaleFactor: 2 },
|
|
out: "packages/docs/images/library-create-skill-modal.png",
|
|
});
|
|
|
|
export const libraryAdvancedSettings = shot("library-advanced-settings", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/settings/extensions`,
|
|
steps: [dismissOverlays, showAdvancedSettings, scrollAdvancedSettingsIntoView],
|
|
expect: ["Advanced settings", "Add workspace MCP"],
|
|
never: ["Your library is empty."],
|
|
out: "packages/docs/images/library-advanced-settings.png",
|
|
});
|
|
|
|
export const libraryAddMcpModal = shot("library-add-mcp-modal", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/settings/extensions`,
|
|
steps: [dismissOverlays, showAdvancedSettings, (surface) => clickButton(surface, "Add workspace MCP", { timeoutMs: 30_000 })],
|
|
expect: ["Add workspace MCP", "App name", "Server URL", "Add App"],
|
|
out: "packages/docs/images/library-add-mcp-modal.png",
|
|
});
|
|
|
|
export const libraryAddMcpSlack = shot("library-add-mcp-slack", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/settings/extensions`,
|
|
steps: [
|
|
dismissOverlays,
|
|
showAdvancedSettings,
|
|
(surface) => clickButton(surface, "Add workspace MCP", { timeoutMs: 30_000 }),
|
|
fillForm({
|
|
'input[placeholder="github-copilot"]': "slack",
|
|
'input[placeholder="https://api.githubcopilot.com/mcp/"]': "https://mcp.slack.com/mcp",
|
|
}),
|
|
keepExpanded("OAuth on this device", "OAuth client ID"),
|
|
],
|
|
expect: ["Add workspace MCP", "App name", "Server URL", "OAuth client ID", "Add App"],
|
|
viewport: { width: 1440, height: 1100, deviceScaleFactor: 2 },
|
|
out: "packages/docs/images/slack-mcp-advanced-oauth.png",
|
|
});
|
|
|
|
export const librarySlackConnection = shot("library-slack-connection", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}/extensions`,
|
|
steps: [dismissOverlays],
|
|
expect: ["Library", "Slack"],
|
|
never: ["Your library is empty."],
|
|
route: /\/extensions$/,
|
|
out: "packages/docs/images/library-slack-connection.png",
|
|
});
|
|
|
|
async function waitForMountedSkillCard(surface: DesktopShotSurface, timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastText = "";
|
|
while (Date.now() < deadline) {
|
|
const targets = await listTargets(surface.handle.cdpUrl);
|
|
const sandbox = targets.find((target) => target.type === "iframe"
|
|
&& target.url.includes("/mcp-apps/sandbox.html")
|
|
&& target.webSocketDebuggerUrl);
|
|
if (sandbox) {
|
|
const client = await connect(debuggerUrlFor(surface.handle.cdpUrl, sandbox));
|
|
try {
|
|
const text = await evaluate(client, `document.querySelector("iframe")?.contentDocument?.body?.innerText ?? ""`);
|
|
if (typeof text === "string") {
|
|
lastText = text;
|
|
const normalized = text.toLocaleLowerCase();
|
|
if (normalized.includes("skill created") && normalized.includes(CHAT_SKILL_NAME)) return;
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
throw new Error(`The skill-created card did not mount with its content. Last iframe text:\n${lastText.slice(0, 600)}`);
|
|
}
|
|
|
|
async function createSkillFromChat(surface: DesktopShotSurface): Promise<void> {
|
|
const reconciled = await inPage(surface, `async (args) => {
|
|
const port = localStorage.getItem("openwork.server.port");
|
|
const token = localStorage.getItem("openwork.server.token");
|
|
if (!port || !token) return "missing local server credentials";
|
|
const response = await fetch("http://127.0.0.1:" + port + "/workspace/" + encodeURIComponent(args.workspaceId) + "/mcp/openwork-cloud/reconcile", {
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
config: {
|
|
type: "remote",
|
|
url: args.mcpUrl,
|
|
enabled: true,
|
|
headers: { Authorization: "Bearer " + args.mcpToken },
|
|
oauth: false,
|
|
},
|
|
provider: args.providerId,
|
|
model: args.modelId,
|
|
trigger: "docs-shots",
|
|
}),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) return "Cloud MCP reconcile failed: " + response.status + " " + text.slice(0, 1000);
|
|
const health = JSON.parse(text);
|
|
if (health?.phase !== "ready") return "Cloud MCP reconcile was not ready: " + JSON.stringify(health).slice(0, 1000);
|
|
return "ok";
|
|
}`, {
|
|
workspaceId: surface.workspaceId,
|
|
mcpUrl: `${surface.organization.den.ref.apiUrl}/mcp/agent`,
|
|
mcpToken: surface.organization.mcpToken,
|
|
providerId: surface.model.providerId,
|
|
modelId: surface.model.modelId,
|
|
}, { awaitPromise: true, timeoutMs: 90_000 });
|
|
if (reconciled !== "ok") throw new Error(`Connecting the Cloud MCP failed: ${String(reconciled)}`);
|
|
|
|
const task = await inPage(surface, `async () => {
|
|
const deadline = Date.now() + 60000;
|
|
let last = null;
|
|
while (Date.now() < deadline) {
|
|
last = await window.__openworkControl.execute("session.create_task", null);
|
|
if (last?.ok === true) return last;
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
return last;
|
|
}`, {}, { awaitPromise: true, timeoutMs: 70_000 });
|
|
if (!isRecord(task) || task.ok !== true) throw new Error(`Creating a task failed: ${JSON.stringify(task)}`);
|
|
await waitFor(surface, `Boolean(document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'))`, {
|
|
timeoutMs: 30_000,
|
|
label: "composer ready",
|
|
});
|
|
const focused = await inPage(surface, `() => {
|
|
const editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]');
|
|
if (!(editor instanceof HTMLElement)) return false;
|
|
editor.focus();
|
|
return true;
|
|
}`, {});
|
|
if (focused !== true) throw new Error("Focusing the composer failed.");
|
|
await surface.client.send("Input.insertText", { text: composerMessage });
|
|
await clickButton(surface, "Run task", { timeoutMs: 30_000 });
|
|
await waitFor(surface, `document.body.innerText.includes(${JSON.stringify(CHAT_CLOSING_REPLY)})`, {
|
|
timeoutMs: 180_000,
|
|
label: "closing reply",
|
|
});
|
|
await waitFor(surface, `Boolean(document.querySelector(${JSON.stringify(`[data-mcp-app-resource="${resourceUri}"] iframe`)}))`, {
|
|
timeoutMs: 60_000,
|
|
label: "skill-created MCP App frame",
|
|
});
|
|
await waitForMountedSkillCard(surface, 60_000);
|
|
await waitFor(surface, `!document.body.innerText.includes("Pulling in the latest messages")`, {
|
|
timeoutMs: 60_000,
|
|
label: "session sync settled",
|
|
});
|
|
await inPage(surface, `(args) => {
|
|
document.querySelector('[data-mcp-app-resource="' + args.resourceUri + '"]')?.scrollIntoView({ block: "center" });
|
|
return true;
|
|
}`, { resourceUri });
|
|
}
|
|
|
|
export const skillCreatedCard = shot("skill-created-card", {
|
|
use: app,
|
|
at: (surface) => `/workspace/${surface.workspaceId}`,
|
|
steps: [createSkillFromChat],
|
|
expect: [CHAT_CLOSING_REPLY],
|
|
never: ["Interactive view unavailable", "MCP_APP_RESOURCE_NOT_FOUND"],
|
|
out: "packages/docs/images/skill-created-mcp-app-card.png",
|
|
});
|