@vellumai/assistant 0.11.9-staging.2 → 0.11.9-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/Dockerfile +9 -0
  2. package/docker-bun-no-autoserve.js +38 -0
  3. package/docker-node-launcher.sh +37 -0
  4. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +8 -0
  5. package/node_modules/@vellumai/avatar-manifest/package.json +2 -1
  6. package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
  7. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
  8. package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
  9. package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
  10. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
  11. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  12. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  17. package/openapi.yaml +116 -1
  18. package/package.json +1 -1
  19. package/src/__tests__/bun-no-autoserve.test.ts +178 -0
  20. package/src/avatar/__tests__/avatar-manifest.test.ts +10 -0
  21. package/src/avatar/__tests__/avatar-store.test.ts +130 -8
  22. package/src/avatar/__tests__/ensure-raster.test.ts +15 -0
  23. package/src/avatar/avatar-accent.ts +57 -0
  24. package/src/avatar/avatar-manifest.ts +20 -2
  25. package/src/avatar/avatar-store.ts +111 -4
  26. package/src/config/bundled-skills/media-processing/SKILL.md +3 -3
  27. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -1
  28. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  29. package/src/platform/sync-avatar.test.ts +3 -0
  30. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +147 -27
  31. package/src/runtime/routes/avatar-routes.ts +84 -28
  32. package/src/runtime/routes/settings-routes.ts +1 -1
  33. package/src/tools/terminal/__tests__/safe-env.test.ts +60 -1
  34. package/src/tools/terminal/safe-env.ts +70 -1
@@ -97,3 +97,24 @@ export function isChannelBotProvider(providerKey: string): boolean {
97
97
  providerKey,
98
98
  );
99
99
  }
100
+
101
+ /**
102
+ * Whether a provider key names the user grant of a brand the assistant is
103
+ * reached through some other way: `slack` beside `slack_channel`, `discord`
104
+ * beside `discord_channel`. The other half of the question
105
+ * `isChannelBotProvider` asks.
106
+ *
107
+ * The two halves carry the same brand name, so the grant reads as the way to
108
+ * connect that brand when the bot is. A surface offering people a way to
109
+ * connect an assistant wants the bot, and wants this to say which keys it is
110
+ * answering for rather than naming them itself.
111
+ *
112
+ * A channel whose bot is its own key has no such pair: `telegram` names the
113
+ * bot, so nothing is standing beside it to be mistaken for.
114
+ */
115
+ export function isChannelUserIntegration(providerKey: string): boolean {
116
+ return Object.entries(CHANNEL_BOT_PROVIDER).some(
117
+ ([channelId, botProviderKey]) =>
118
+ channelId === providerKey && botProviderKey !== providerKey,
119
+ );
120
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { CHANNEL_IDS, isChannelId } from "../channels.js";
3
+ import {
4
+ CHANNEL_BOT_PROVIDER,
5
+ CHANNEL_IDS,
6
+ isChannelBotProvider,
7
+ isChannelUserIntegration,
8
+ isChannelId,
9
+ } from "../channels.js";
4
10
 
5
11
  describe("isChannelId", () => {
6
12
  test("accepts every canonical channel id", () => {
@@ -34,3 +40,36 @@ describe("isChannelId", () => {
34
40
  expect(isChannelId(42)).toBe(false);
35
41
  });
36
42
  });
43
+
44
+ describe("isChannelUserIntegration", () => {
45
+ test("names the grant standing beside a bot of the same brand", () => {
46
+ expect(isChannelUserIntegration("slack")).toBe(true);
47
+ expect(isChannelUserIntegration("discord")).toBe(true);
48
+ });
49
+
50
+ test("excludes a channel whose bot is its own key", () => {
51
+ // `telegram` names the bot, so no second provider carries the brand and
52
+ // there is nothing to mistake for it.
53
+ expect(isChannelUserIntegration("telegram")).toBe(false);
54
+ });
55
+
56
+ test("excludes the bots themselves and unrelated providers", () => {
57
+ expect(isChannelUserIntegration("slack_channel")).toBe(false);
58
+ expect(isChannelUserIntegration("discord_channel")).toBe(false);
59
+ expect(isChannelUserIntegration("google")).toBe(false);
60
+ });
61
+
62
+ test("is the complement of isChannelBotProvider over the map", () => {
63
+ // The two questions partition the brands that have both halves: a key is
64
+ // one or the other, never both, so a caller picking the wrong one gets an
65
+ // empty answer rather than a plausible wrong one.
66
+ for (const [channelId, botProviderKey] of Object.entries(
67
+ CHANNEL_BOT_PROVIDER,
68
+ )) {
69
+ expect(isChannelUserIntegration(channelId)).toBe(
70
+ channelId !== botProviderKey,
71
+ );
72
+ expect(isChannelBotProvider(botProviderKey)).toBe(true);
73
+ }
74
+ });
75
+ });
@@ -97,3 +97,24 @@ export function isChannelBotProvider(providerKey: string): boolean {
97
97
  providerKey,
98
98
  );
99
99
  }
100
+
101
+ /**
102
+ * Whether a provider key names the user grant of a brand the assistant is
103
+ * reached through some other way: `slack` beside `slack_channel`, `discord`
104
+ * beside `discord_channel`. The other half of the question
105
+ * `isChannelBotProvider` asks.
106
+ *
107
+ * The two halves carry the same brand name, so the grant reads as the way to
108
+ * connect that brand when the bot is. A surface offering people a way to
109
+ * connect an assistant wants the bot, and wants this to say which keys it is
110
+ * answering for rather than naming them itself.
111
+ *
112
+ * A channel whose bot is its own key has no such pair: `telegram` names the
113
+ * bot, so nothing is standing beside it to be mistaken for.
114
+ */
115
+ export function isChannelUserIntegration(providerKey: string): boolean {
116
+ return Object.entries(CHANNEL_BOT_PROVIDER).some(
117
+ ([channelId, botProviderKey]) =>
118
+ channelId === providerKey && botProviderKey !== providerKey,
119
+ );
120
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { CHANNEL_IDS, isChannelId } from "../channels.js";
3
+ import {
4
+ CHANNEL_BOT_PROVIDER,
5
+ CHANNEL_IDS,
6
+ isChannelBotProvider,
7
+ isChannelUserIntegration,
8
+ isChannelId,
9
+ } from "../channels.js";
4
10
 
5
11
  describe("isChannelId", () => {
6
12
  test("accepts every canonical channel id", () => {
@@ -34,3 +40,36 @@ describe("isChannelId", () => {
34
40
  expect(isChannelId(42)).toBe(false);
35
41
  });
36
42
  });
43
+
44
+ describe("isChannelUserIntegration", () => {
45
+ test("names the grant standing beside a bot of the same brand", () => {
46
+ expect(isChannelUserIntegration("slack")).toBe(true);
47
+ expect(isChannelUserIntegration("discord")).toBe(true);
48
+ });
49
+
50
+ test("excludes a channel whose bot is its own key", () => {
51
+ // `telegram` names the bot, so no second provider carries the brand and
52
+ // there is nothing to mistake for it.
53
+ expect(isChannelUserIntegration("telegram")).toBe(false);
54
+ });
55
+
56
+ test("excludes the bots themselves and unrelated providers", () => {
57
+ expect(isChannelUserIntegration("slack_channel")).toBe(false);
58
+ expect(isChannelUserIntegration("discord_channel")).toBe(false);
59
+ expect(isChannelUserIntegration("google")).toBe(false);
60
+ });
61
+
62
+ test("is the complement of isChannelBotProvider over the map", () => {
63
+ // The two questions partition the brands that have both halves: a key is
64
+ // one or the other, never both, so a caller picking the wrong one gets an
65
+ // empty answer rather than a plausible wrong one.
66
+ for (const [channelId, botProviderKey] of Object.entries(
67
+ CHANNEL_BOT_PROVIDER,
68
+ )) {
69
+ expect(isChannelUserIntegration(channelId)).toBe(
70
+ channelId !== botProviderKey,
71
+ );
72
+ expect(isChannelBotProvider(botProviderKey)).toBe(true);
73
+ }
74
+ });
75
+ });
@@ -97,3 +97,24 @@ export function isChannelBotProvider(providerKey: string): boolean {
97
97
  providerKey,
98
98
  );
99
99
  }
100
+
101
+ /**
102
+ * Whether a provider key names the user grant of a brand the assistant is
103
+ * reached through some other way: `slack` beside `slack_channel`, `discord`
104
+ * beside `discord_channel`. The other half of the question
105
+ * `isChannelBotProvider` asks.
106
+ *
107
+ * The two halves carry the same brand name, so the grant reads as the way to
108
+ * connect that brand when the bot is. A surface offering people a way to
109
+ * connect an assistant wants the bot, and wants this to say which keys it is
110
+ * answering for rather than naming them itself.
111
+ *
112
+ * A channel whose bot is its own key has no such pair: `telegram` names the
113
+ * bot, so nothing is standing beside it to be mistaken for.
114
+ */
115
+ export function isChannelUserIntegration(providerKey: string): boolean {
116
+ return Object.entries(CHANNEL_BOT_PROVIDER).some(
117
+ ([channelId, botProviderKey]) =>
118
+ channelId === providerKey && botProviderKey !== providerKey,
119
+ );
120
+ }
package/openapi.yaml CHANGED
@@ -2019,6 +2019,103 @@ paths:
2019
2019
  - userId
2020
2020
  - authenticated
2021
2021
  additionalProperties: false
2022
+ /v1/avatar/accent:
2023
+ post:
2024
+ operationId: avatar_accent_post
2025
+ summary: Set avatar accent
2026
+ description:
2027
+ "Set the accent colour over the current avatar as #rrggbb, or null to return to the automatic one (the
2028
+ character's palette colour, or the colour read out of the uploaded image)."
2029
+ tags:
2030
+ - avatar
2031
+ requestBody:
2032
+ required: true
2033
+ content:
2034
+ application/json:
2035
+ schema:
2036
+ type: object
2037
+ properties:
2038
+ hex:
2039
+ anyOf:
2040
+ - type: string
2041
+ - type: "null"
2042
+ required:
2043
+ - hex
2044
+ responses:
2045
+ "200":
2046
+ description: Successful response
2047
+ content:
2048
+ application/json:
2049
+ schema:
2050
+ type: object
2051
+ properties:
2052
+ kind:
2053
+ type: string
2054
+ enum:
2055
+ - character
2056
+ - image
2057
+ - none
2058
+ traits:
2059
+ anyOf:
2060
+ - type: object
2061
+ properties:
2062
+ bodyShape:
2063
+ type: string
2064
+ eyeStyle:
2065
+ type: string
2066
+ color:
2067
+ type: string
2068
+ required:
2069
+ - bodyShape
2070
+ - eyeStyle
2071
+ - color
2072
+ additionalProperties: false
2073
+ - type: "null"
2074
+ source:
2075
+ anyOf:
2076
+ - type: string
2077
+ enum:
2078
+ - builder
2079
+ - upload
2080
+ - ai
2081
+ - type: "null"
2082
+ image:
2083
+ anyOf:
2084
+ - type: object
2085
+ properties:
2086
+ updatedAt:
2087
+ type: string
2088
+ etag:
2089
+ type: string
2090
+ required:
2091
+ - updatedAt
2092
+ - etag
2093
+ additionalProperties: false
2094
+ - type: "null"
2095
+ accent:
2096
+ anyOf:
2097
+ - type: object
2098
+ properties:
2099
+ hex:
2100
+ type: string
2101
+ source:
2102
+ type: string
2103
+ enum:
2104
+ - palette
2105
+ - derived
2106
+ - custom
2107
+ required:
2108
+ - hex
2109
+ - source
2110
+ additionalProperties: false
2111
+ - type: "null"
2112
+ required:
2113
+ - kind
2114
+ - traits
2115
+ - source
2116
+ - image
2117
+ - accent
2118
+ additionalProperties: false
2022
2119
  /v1/avatar/character-components:
2023
2120
  get:
2024
2121
  operationId: avatar_charactercomponents_get
@@ -2390,7 +2487,7 @@ paths:
2390
2487
  get:
2391
2488
  operationId: avatar_state_get
2392
2489
  summary: Get avatar state
2393
- description: Return the authoritative avatar render mode (character, image, or none).
2490
+ description: Return the authoritative avatar render mode (character, image, or none) and its accent colour.
2394
2491
  tags:
2395
2492
  - avatar
2396
2493
  responses:
@@ -2444,11 +2541,29 @@ paths:
2444
2541
  - etag
2445
2542
  additionalProperties: false
2446
2543
  - type: "null"
2544
+ accent:
2545
+ anyOf:
2546
+ - type: object
2547
+ properties:
2548
+ hex:
2549
+ type: string
2550
+ source:
2551
+ type: string
2552
+ enum:
2553
+ - palette
2554
+ - derived
2555
+ - custom
2556
+ required:
2557
+ - hex
2558
+ - source
2559
+ additionalProperties: false
2560
+ - type: "null"
2447
2561
  required:
2448
2562
  - kind
2449
2563
  - traits
2450
2564
  - source
2451
2565
  - image
2566
+ - accent
2452
2567
  additionalProperties: false
2453
2568
  /v1/background-tools:
2454
2569
  get:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.9-staging.2",
3
+ "version": "0.11.9-staging.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,178 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+
13
+ /**
14
+ * Guard for `assistant/docker-bun-no-autoserve.js`, the preload that stops
15
+ * `bun:main` turning a CLI whose default export has a `fetch` method into a
16
+ * long-lived HTTP server. The image has no Node, so `bunx <pkg>` runs such a
17
+ * CLI under Bun via the `node` launcher the Dockerfile installs, and without
18
+ * the shim the process never exits.
19
+ *
20
+ * The shim recognizes the implicit call by its `bun:main` caller frame. If a
21
+ * Bun upgrade renames that frame the shim fails open (every server starts for
22
+ * real) and these fixtures catch it.
23
+ */
24
+
25
+ // Tests run with `assistant/` as the working directory.
26
+ const SHIM_PATH = join(process.cwd(), "docker-bun-no-autoserve.js");
27
+ const DOCKERFILE_PATH = join(process.cwd(), "Dockerfile");
28
+ const LAUNCHER_PATH = join(process.cwd(), "docker-node-launcher.sh");
29
+ const RUN_TIMEOUT_MS = 10_000;
30
+
31
+ let fixtureDir: string;
32
+
33
+ function writeFixture(name: string, source: string): string {
34
+ const path = join(fixtureDir, name);
35
+ writeFileSync(path, source);
36
+ return path;
37
+ }
38
+
39
+ async function runWithShim(
40
+ fixturePath: string,
41
+ ): Promise<{ exitCode: number | null; output: string }> {
42
+ const proc = Bun.spawn(
43
+ [process.execPath, `--preload=${SHIM_PATH}`, "run", fixturePath],
44
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
45
+ );
46
+ const timer = setTimeout(() => {
47
+ proc.kill();
48
+ }, RUN_TIMEOUT_MS);
49
+ try {
50
+ const [stdout, stderr] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ ]);
54
+ await proc.exited;
55
+ return { exitCode: proc.exitCode, output: `${stdout}${stderr}` };
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ describe("Bun auto-serve shim", () => {
62
+ beforeAll(() => {
63
+ fixtureDir = mkdtempSync(join(tmpdir(), "bun-no-autoserve-"));
64
+ });
65
+
66
+ afterAll(() => {
67
+ rmSync(fixtureDir, { recursive: true, force: true });
68
+ });
69
+
70
+ test("a default export with fetch exits instead of starting a server", async () => {
71
+ const fixture = writeFixture(
72
+ "auto-serve.js",
73
+ 'export default { fetch: () => new Response("x") };\n',
74
+ );
75
+
76
+ const { exitCode, output } = await runWithShim(fixture);
77
+
78
+ expect(exitCode).toBe(0);
79
+ expect(output).not.toContain("Started ");
80
+ });
81
+
82
+ test("an explicit Bun.serve call still starts a real server", async () => {
83
+ const fixture = writeFixture(
84
+ "explicit-serve.js",
85
+ [
86
+ 'const server = Bun.serve({ port: 0, fetch: () => new Response("x") });',
87
+ // Written raw so Bun's console formatting does not colorize the number.
88
+ 'process.stdout.write("port=" + server.port + "\\n");',
89
+ "server.stop(true);",
90
+ "",
91
+ ].join("\n"),
92
+ );
93
+
94
+ const { exitCode, output } = await runWithShim(fixture);
95
+
96
+ expect(exitCode).toBe(0);
97
+ const port = Number(/port=(\d+)/.exec(output)?.[1]);
98
+ expect(port).toBeGreaterThan(0);
99
+ });
100
+
101
+ test("the Dockerfile installs the node launcher, which preloads the shim", () => {
102
+ const dockerfile = readFileSync(DOCKERFILE_PATH, "utf8");
103
+ const launcher = readFileSync(LAUNCHER_PATH, "utf8");
104
+
105
+ expect(dockerfile).toContain(
106
+ "ln -s /app/assistant/docker-node-launcher.sh /usr/local/bin/node",
107
+ );
108
+ expect(launcher).toContain(
109
+ "shim=/app/assistant/docker-bun-no-autoserve.js",
110
+ );
111
+ expect(launcher).toContain(
112
+ 'exec /usr/local/bin/bun --preload="$shim" "$@"',
113
+ );
114
+ });
115
+ });
116
+
117
+ // The launcher only falls back to Bun. A Node installed after the image was
118
+ // built (apt, or the persistent Kata apt root) has to win, or Node CLIs keep
119
+ // running under Bun semantics forever.
120
+ describe.skipIf(process.platform === "win32")(
121
+ "node launcher PATH handoff",
122
+ () => {
123
+ let binDir: string;
124
+
125
+ function writeStubNode(dir: string, marker: string): void {
126
+ mkdirSync(dir, { recursive: true });
127
+ const path = join(dir, "node");
128
+ writeFileSync(path, `#!/bin/sh\necho ${marker}\n`);
129
+ chmodSync(path, 0o755);
130
+ }
131
+
132
+ async function runLauncher(
133
+ path: string,
134
+ ): Promise<{ exitCode: number | null; output: string }> {
135
+ const proc = Bun.spawn(["/bin/sh", LAUNCHER_PATH, "--version"], {
136
+ env: { PATH: path },
137
+ stdout: "pipe",
138
+ stderr: "pipe",
139
+ windowsHide: true,
140
+ });
141
+ const [stdout, stderr] = await Promise.all([
142
+ new Response(proc.stdout).text(),
143
+ new Response(proc.stderr).text(),
144
+ ]);
145
+ await proc.exited;
146
+ return { exitCode: proc.exitCode, output: `${stdout}${stderr}` };
147
+ }
148
+
149
+ beforeAll(() => {
150
+ binDir = mkdtempSync(join(tmpdir(), "node-launcher-"));
151
+ });
152
+
153
+ afterAll(() => {
154
+ rmSync(binDir, { recursive: true, force: true });
155
+ });
156
+
157
+ test("delegates to a real node found on PATH", async () => {
158
+ const realDir = join(binDir, "real");
159
+ writeStubNode(realDir, "REAL_NODE");
160
+
161
+ const { exitCode, output } = await runLauncher(realDir);
162
+
163
+ expect(exitCode).toBe(0);
164
+ expect(output).toContain("REAL_NODE");
165
+ });
166
+
167
+ test("ignores Bun's own node shim dir", async () => {
168
+ const shimDir = join(binDir, "bun-node-deadbeef");
169
+ writeStubNode(shimDir, "BUN_SHIM");
170
+
171
+ const { output } = await runLauncher(shimDir);
172
+
173
+ // With no real node anywhere it falls through to the image's bun rather
174
+ // than running the shim, which is the unpreloaded Bun that hangs.
175
+ expect(output).not.toContain("BUN_SHIM");
176
+ });
177
+ },
178
+ );
@@ -30,6 +30,7 @@ describe("avatar-manifest", () => {
30
30
  traits: VALID_TRAITS,
31
31
  source: "builder",
32
32
  image: null,
33
+ accent: null,
33
34
  };
34
35
  writeManifest(state, dir);
35
36
  expect(readManifest(dir)).toEqual(state);
@@ -41,6 +42,7 @@ describe("avatar-manifest", () => {
41
42
  traits: null,
42
43
  source: "upload",
43
44
  image: { updatedAt: "2024-01-01T00:00:00.000Z", etag: "abc123" },
45
+ accent: null,
44
46
  };
45
47
  writeManifest(state, dir);
46
48
  expect(readManifest(dir)).toEqual(state);
@@ -63,6 +65,7 @@ describe("avatar-manifest", () => {
63
65
  traits: null,
64
66
  source: null,
65
67
  image: null,
68
+ accent: null,
66
69
  }),
67
70
  );
68
71
  expect(readManifest(dir)).toBeNull();
@@ -81,6 +84,7 @@ describe("avatar-manifest", () => {
81
84
  traits: null,
82
85
  source: null,
83
86
  image: null,
87
+ accent: null,
84
88
  }),
85
89
  );
86
90
  expect(readManifest(dir)).toBeNull();
@@ -102,6 +106,7 @@ describe("avatar-manifest", () => {
102
106
  traits: { bodyShape: "round" },
103
107
  source: null,
104
108
  image: null,
109
+ accent: null,
105
110
  }),
106
111
  );
107
112
  expect(readManifest(dir)).toBeNull();
@@ -115,6 +120,7 @@ describe("avatar-manifest", () => {
115
120
  traits: null,
116
121
  source: null,
117
122
  image: null,
123
+ accent: null,
118
124
  }),
119
125
  );
120
126
  expect(readManifest(dir)).toBeNull();
@@ -136,6 +142,7 @@ describe("avatar-manifest", () => {
136
142
  traits: null,
137
143
  source: null,
138
144
  image: { updatedAt: "2024-01-01T00:00:00.000Z", etag: "" },
145
+ accent: null,
139
146
  }),
140
147
  );
141
148
  expect(readManifest(dir)).toBeNull();
@@ -149,6 +156,7 @@ describe("avatar-manifest", () => {
149
156
  traits: null,
150
157
  source: null,
151
158
  image: { etag: "abc123" },
159
+ accent: null,
152
160
  }),
153
161
  );
154
162
  expect(readManifest(dir)).toBeNull();
@@ -160,6 +168,7 @@ describe("avatar-manifest", () => {
160
168
  traits: null,
161
169
  source: null,
162
170
  image: null,
171
+ accent: null,
163
172
  };
164
173
  writeFileSync(join(dir, "avatar.json"), JSON.stringify({ kind: "none" }));
165
174
  expect(readManifest(dir)).toEqual(state);
@@ -230,6 +239,7 @@ describe("avatar-manifest", () => {
230
239
  traits: null,
231
240
  source: null,
232
241
  image: null,
242
+ accent: null,
233
243
  });
234
244
  });
235
245
  });