Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,20 @@ jobs:

- name: Test (agent-sdk vitest suite)
run: pnpm --filter @iqlabs-official/agent-sdk test
env:
# the panel Chrome layout pass must run here; a missing browser is a failure, never a skip
PANEL_CHROME_REQUIRED: "1"

# The runtime smoke test (pnpm run test:run) is intentionally NOT run here:
# it drives the real claude + codex CLIs and needs them installed and logged
# in, which a CI runner has not. Keep it as a local/manual integration check.

- name: Typecheck agent-sdk
run: pnpm --filter @iqlabs-official/agent-sdk exec tsc --noEmit
# the panel modules need the DOM lib, which the root tsconfig excludes (cli/localhost typecheck
# core with lib ES2022 only), so they get their own tsconfig and their own gate
- name: Typecheck panel (DOM)
run: pnpm --filter @iqlabs-official/agent-sdk typecheck:panel
- name: Typecheck CLI
run: pnpm --filter agentnet-cli exec tsc --noEmit
- name: Typecheck localhost
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"test:run": "pnpm --filter @iqlabs-official/agent-sdk test:run",
"build:core": "pnpm --filter @iqlabs-official/agent-sdk build",
"build:panel": "pnpm --filter @iqlabs-official/agent-sdk build:panel",
"build:vscode": "pnpm --filter agentnet-vscode build",
"build:cli": "pnpm --filter agentnet-cli build",
"dev:cli": "pnpm --filter agentnet-cli dev"
Expand Down
7 changes: 6 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"test": "vitest run",
"test:run": "tsx test/test-runtime.ts",
"test:memory": "tsx test/test-memory.ts",
"test:skills": "tsx test/test-skills.ts"
"test:skills": "tsx test/test-skills.ts",
"build:panel": "tsx scripts/buildPanel.ts",
"typecheck:panel": "tsc -p src/chat/ui/panel/tsconfig.json",
"test:panel:chrome": "PANEL_CHROME_REQUIRED=1 vitest run src/chat/ui/panel.chrome.spec.ts"
},
"peerDependencies": {
"@iqlabs-official/solana-sdk": "^0.1.28",
Expand All @@ -23,7 +26,9 @@
"devDependencies": {
"@iqlabs-official/solana-sdk": "^0.1.28",
"@solana/web3.js": "^1.98.0",
"@types/jsdom": "^30.0.0",
"@types/node": "^20.0.0",
"jsdom": "^30.0.1",
"tsup": "^8.0.0",
"tsx": "^4.19.2",
"tweetnacl": "^1.0.3",
Expand Down
110 changes: 110 additions & 0 deletions packages/core/scripts/buildPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Builds the chat panel script from src/chat/ui/panel/main.ts (real ES modules) into ONE browser
// IIFE and writes it into src/chat/ui/panel.generated.ts as a string constant. WHY a committed
// generated file, like mdLibs.generated.ts: core is consumed as raw .ts by every surface's bundler
// and typechecker, so chatHtml() must inline the panel with zero runtime file access, and an
// ignored artifact would add a mandatory pre-step to every tsc/tsup/vsce invocation.
// panel.generated.spec.ts rebuilds the bundle in-process and fails when the committed file is stale.
//
// pnpm build:panel rebuild once
// pnpm build:panel --watch rebuild on every change under panel/
import { build, type Options } from "tsup";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const CORE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const ENTRY = join(CORE_ROOT, "src", "chat", "ui", "panel", "main.ts");
const DEST = join(CORE_ROOT, "src", "chat", "ui", "panel.generated.ts");
const OUT_NAME = "panel.global.js"; // tsup's file name for an iife entry called "panel"

function buildOptions(outDir: string): Options {
return {
entry: { panel: ENTRY },
format: ["iife"],
platform: "browser",
target: "es2022",
// tsup resolves a tsconfig from process.cwd() unless told otherwise, so without this the
// output depended on the invoking directory: the panel's own config is the one that
// describes these modules, and the strict directive is emitted deliberately below
// instead of being inherited from whichever tsconfig happened to be found.
tsconfig: join(CORE_ROOT, "src", "chat", "ui", "panel", "tsconfig.json"),
banner: { js: '"use strict";' },
// tsup's rollup treeshake pass rewrote data (it dropped CHAT_MODEL_OPTIONS properties and an
// unused `let` write when this migration was tried); esbuild alone keeps the code verbatim.
treeshake: false,
minify: false,
sourcemap: false,
dts: false,
splitting: false,
clean: false,
silent: true,
config: false,
outDir,
esbuildOptions(o) {
o.charset = "utf8"; // keep the Korean UI strings literal instead of \u escapes
o.legalComments = "none";
o.absWorkingDir = CORE_ROOT; // path comments must not depend on the invoking cwd (determinism)
},
};
}

// The un-escape sentinels: the legacy script was a template literal whose backslashes were
// doubled, so a surviving `\\n` or a `</script` would mean the cut regressed to escaped text.
function assertBundle(js: string): void {
const fail = (why: string) => { throw new Error("panel bundle: " + why); };
if (!js.startsWith('"use strict";')) fail("does not start with \"use strict\"");
if (js.includes("</script")) fail("contains </script (would end the inline <script> tag)");
if (js.includes("process.")) fail("contains process. (node-only code reached the browser bundle)");
if (js.includes("require(")) fail("contains require( (a CommonJS dependency reached the bundle)");
const escaped = js.split("\n").filter((l) => l.includes("\\\\n") && !l.trim().startsWith("//"));
if (escaped.length) fail("double-escaped newline outside a comment: " + escaped[0].trim().slice(0, 80));
}

export async function bundlePanel(): Promise<string> {
const outDir = mkdtempSync(join(tmpdir(), "agentnet-panel-"));
try {
await build(buildOptions(outDir));
const js = readFileSync(join(outDir, OUT_NAME), "utf8");
assertBundle(js);
return js;
} finally {
rmSync(outDir, { recursive: true, force: true });
}
}

// A JSON string literal is a valid JS string literal: no decoder is needed and the text stays
// grep-able; non-ASCII stays literal (the file is UTF-8 like webview.ts).
export function renderPanelModule(js: string): string {
return (
"// AUTO-GENERATED by scripts/buildPanel.ts from src/chat/ui/panel/ -- DO NOT EDIT; re-run pnpm build:panel\n" +
`export const PANEL_SCRIPT = ${JSON.stringify(js)};\n`
);
}

function writeGenerated(js: string): void {
writeFileSync(DEST, renderPanelModule(js), "utf8");
console.log(`wrote ${DEST} (${(js.length / 1024).toFixed(0)} KB)`);
}

async function main(): Promise<void> {
if (!process.argv.includes("--watch")) {
writeGenerated(await bundlePanel());
return;
}
// watch mode keeps its temp dir for the process lifetime; each successful rebuild rewrites DEST
const outDir = mkdtempSync(join(tmpdir(), "agentnet-panel-watch-"));
await build({
...buildOptions(outDir),
watch: true,
onSuccess: async () => {
const js = readFileSync(join(outDir, OUT_NAME), "utf8");
assertBundle(js);
writeGenerated(js);
},
});
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((e) => { console.error(e); process.exit(1); });
}
25 changes: 25 additions & 0 deletions packages/core/src/chat/ui/avatar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Pins hashSeed/avatarSvg to the goldens captured from the string-shipped AVATAR_SCRIPT right
// before the #215 module port, so the real exports cannot drift from what the panel rendered.
import { describe, it, expect } from "vitest";
import { hashSeed, avatarSvg } from "./avatar.js";
import goldens from "../../../test/fixtures/panel/avatarGoldens.json";

describe("chat/ui/avatar: hashSeed and avatarSvg match the pre-migration goldens", () => {
it("reproduces every golden byte-for-byte", () => {
expect(goldens).toHaveLength(5);
for (const g of goldens) {
expect(hashSeed(g.seed)).toBe(g.hash);
expect(avatarSvg(g.seed)).toBe(g.svg);
}
});

it("drops the shared <style> block so two avatars on one page cannot recolor each other", () => {
for (const g of goldens) expect(avatarSvg(g.seed)).not.toContain("<style>");
});

it("gives different seeds different faces; the empty seed is the 'default' face", () => {
const named = goldens.filter((g) => g.seed !== "");
expect(new Set(named.map((g) => avatarSvg(g.seed))).size).toBe(named.length);
expect(avatarSvg("")).toBe(avatarSvg("default"));
});
});
Loading
Loading