Skip to content

Commit f74d966

Browse files
committed
fix tests
1 parent 453057b commit f74d966

8 files changed

Lines changed: 289 additions & 34 deletions

File tree

e2e/cesium-viewer-tools-live.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const INPUT_SELECTOR = '[data-testid="chat-input-wrapper"] input';
2929
interface ViewerSnapshot {
3030
entities: number;
3131
imageryLayers: number;
32+
dataSources: number;
3233
cameraPosition: { x: number; y: number; z: number };
3334
enableLighting: boolean;
3435
clockMultiplier: number;
@@ -48,6 +49,7 @@ async function getViewerSnapshot(page: Page): Promise<ViewerSnapshot> {
4849
return {
4950
entities: viewer.entities.values.length,
5051
imageryLayers: viewer.imageryLayers.length,
52+
dataSources: viewer.dataSources.length,
5153
cameraPosition: { x: camera.position.x, y: camera.position.y, z: camera.position.z },
5254
enableLighting: viewer.scene.globe.enableLighting,
5355
clockMultiplier: viewer.clock.multiplier,
@@ -685,6 +687,62 @@ test.describe("Cesium viewer tools — end-to-end against the live backend", ()
685687
.toBe(0);
686688
});
687689

690+
// ---- geoJson tools ---------------------------------------------------------------------------
691+
692+
const GEO_JSON_ADD_PROMPT =
693+
"Using the geoJsonAdd tool, render this GeoJSON polygon on the globe (pass it directly, do " +
694+
'not use any other tool first): {"type":"Feature","properties":{},"geometry":{"type":' +
695+
'"Polygon","coordinates":[[[-0.5,51.3],[0.2,51.3],[0.2,51.7],[-0.5,51.7],[-0.5,51.3]]]}}. ' +
696+
'Name it "geojson-test-zone".';
697+
698+
test("geoJsonAdd", async ({ page }) => {
699+
test.setTimeout(3 * 60_000);
700+
701+
const before = await getViewerSnapshot(page);
702+
703+
const result = await runToolStep(page, { prompt: GEO_JSON_ADD_PROMPT, toolName: "geoJsonAdd" });
704+
705+
expect(result.name).toBe("geojson-test-zone");
706+
expect(typeof result.entityCount, "expected result.entityCount to be a number").toBe("number");
707+
expect(result.entityCount as number).toBeGreaterThan(0);
708+
709+
// The tool result carries no view-state confirmation beyond entityCount — cross-check against
710+
// the live Viewer's own dataSources/entities collections, not just the reported success.
711+
await expect
712+
.poll(async () => (await getViewerSnapshot(page)).dataSources, {
713+
message: "expected a new GeoJSON data source to be added",
714+
timeout: 10_000,
715+
})
716+
.toBeGreaterThan(before.dataSources);
717+
});
718+
719+
test("geoJsonRemove", async ({ page }) => {
720+
test.setTimeout(5 * 60_000);
721+
722+
const before = await getViewerSnapshot(page);
723+
724+
await runToolStep(page, { prompt: GEO_JSON_ADD_PROMPT, toolName: "geoJsonAdd" });
725+
726+
await expect
727+
.poll(async () => (await getViewerSnapshot(page)).dataSources, {
728+
message: "expected the GeoJSON data source to be added",
729+
timeout: 10_000,
730+
})
731+
.toBeGreaterThan(before.dataSources);
732+
733+
await runToolStep(page, {
734+
prompt: 'Using geoJsonRemove, remove the GeoJSON data source named "geojson-test-zone".',
735+
toolName: "geoJsonRemove",
736+
});
737+
738+
await expect
739+
.poll(async () => (await getViewerSnapshot(page)).dataSources, {
740+
message: "expected the GeoJSON data source to be removed",
741+
timeout: 10_000,
742+
})
743+
.toBe(before.dataSources);
744+
});
745+
688746
// ---- flyTo ----------------------------------------------------------------------------------
689747

690748
/**

e2e/turf-tools-live.spec.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,113 @@ test.describe("Turf.js tools — end-to-end against the live backend", () => {
188188
expect(geojson.properties?.name).toBe("test-point");
189189
});
190190
});
191+
192+
/**
193+
* Regression coverage for a real bug: `geoJsonAdd` (a client-side `@cesium-ai/tools-schemas` tool,
194+
* not a Turf tool) crashed with an opaque "Cannot read properties of undefined (reading 'length')"
195+
* when the model passed it a structurally-loose GeoJSON object missing `features`/`geometry` —
196+
* exactly the shape a model can end up constructing when relaying a Turf tool's dataset back out
197+
* (see `packages/tools-schemas/src/tools/geoJsonAdd/geoJsonAdd.schema.ts`'s fix and this repo's
198+
* `resolve-geojson.ts` for the identical, earlier-fixed bug class in Turf tools themselves).
199+
*
200+
* These tests exercise the full real-world chain this app is meant to support end-to-end against
201+
* the live backend AND the live CesiumJS `Viewer` (unlike the tests above, which only assert on
202+
* tool-result JSON): a Turf tool produces/stores a dataset, `turf_get_dataset` fetches its full
203+
* GeoJSON back into the conversation, and `geoJsonAdd` renders that GeoJSON on the globe. Viewer
204+
* state is read via the dev-only `window.__cesiumViewerForE2E` seam, same convention as
205+
* `cesium-viewer-tools-live.spec.ts`.
206+
*/
207+
test.describe("Turf.js output rendered on the globe via geoJsonAdd", () => {
208+
test.beforeEach(async ({ page }) => {
209+
await page.goto("/");
210+
await page.waitForSelector(INPUT_SELECTOR, { timeout: 30_000 });
211+
});
212+
213+
async function getDataSourceCount(page: Page): Promise<number> {
214+
return page.evaluate(() => {
215+
const viewer = (window as unknown as { __cesiumViewerForE2E?: any }).__cesiumViewerForE2E;
216+
if (!viewer) {
217+
throw new Error(
218+
"window.__cesiumViewerForE2E is undefined — is the app running in dev mode " +
219+
"(`npm run dev:frontend`), and has CesiumGlobe finished mounting?",
220+
);
221+
}
222+
return viewer.dataSources.length as number;
223+
});
224+
}
225+
226+
test("turf_buffer dataset -> turf_get_dataset -> geoJsonAdd renders a new data source", async ({
227+
page,
228+
}) => {
229+
test.setTimeout(3 * 60_000);
230+
231+
const before = await getDataSourceCount(page);
232+
233+
const buffered = await runToolStep(page, {
234+
prompt:
235+
"Using the turf_buffer tool, buffer this GeoJSON point by 500 meters (do not register it " +
236+
'first, pass it directly): {"type":"Feature","properties":{},"geometry":{"type":"Point",' +
237+
'"coordinates":[-0.1278,51.5074]}}',
238+
toolName: "turf_buffer",
239+
});
240+
const datasetId = buffered.dataset_id as string;
241+
expect(typeof datasetId).toBe("string");
242+
243+
await runToolStep(page, {
244+
prompt: `Using the turf_get_dataset tool, fetch the full GeoJSON for dataset_id "${datasetId}".`,
245+
toolName: "turf_get_dataset",
246+
});
247+
248+
const rendered = await runToolStep(page, {
249+
prompt:
250+
"Now using the geoJsonAdd tool, render the exact GeoJSON returned by the previous " +
251+
'turf_get_dataset call on the globe. Name it "turf-buffer-zone" and use a red stroke color.',
252+
toolName: "geoJsonAdd",
253+
});
254+
255+
expect(rendered.success).toBe(true);
256+
expect(rendered.name).toBe("turf-buffer-zone");
257+
expect(typeof rendered.entityCount).toBe("number");
258+
expect(rendered.entityCount as number).toBeGreaterThan(0);
259+
260+
const after = await getDataSourceCount(page);
261+
expect(after).toBe(before + 1);
262+
});
263+
264+
test("turf_hex_grid dataset -> turf_get_dataset -> geoJsonAdd renders a new data source", async ({
265+
page,
266+
}) => {
267+
test.setTimeout(3 * 60_000);
268+
269+
const before = await getDataSourceCount(page);
270+
271+
const hexGrid = await runToolStep(page, {
272+
prompt:
273+
"Using the turf_hex_grid tool, generate a hexagonal grid (no point aggregation) over the " +
274+
"bounding box west -0.5, south 51.3, east 0.2, north 51.7, with a cell side of 5 kilometers.",
275+
toolName: "turf_hex_grid",
276+
});
277+
const datasetId = hexGrid.dataset_id as string;
278+
expect(typeof datasetId).toBe("string");
279+
280+
await runToolStep(page, {
281+
prompt: `Using the turf_get_dataset tool, fetch the full GeoJSON for dataset_id "${datasetId}".`,
282+
toolName: "turf_get_dataset",
283+
});
284+
285+
const rendered = await runToolStep(page, {
286+
prompt:
287+
"Now using the geoJsonAdd tool, render the exact GeoJSON returned by the previous " +
288+
'turf_get_dataset call on the globe. Name it "turf-hex-grid".',
289+
toolName: "geoJsonAdd",
290+
});
291+
292+
expect(rendered.success).toBe(true);
293+
expect(rendered.name).toBe("turf-hex-grid");
294+
expect(typeof rendered.entityCount).toBe("number");
295+
expect(rendered.entityCount as number).toBeGreaterThan(0);
296+
297+
const after = await getDataSourceCount(page);
298+
expect(after).toBe(before + 1);
299+
});
300+
});

packages/chat-element/src/chat-client/chat-client.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,40 @@ export class ChatClient {
189189
}
190190
}
191191

192+
// A tool call the server already fully resolved within THIS stream (its
193+
// `tool-output-available`/`tool-output-error` chunk arrived right after
194+
// `tool-input-available`, before the stream finished) is still recorded in
195+
// `pendingToolCalls` by `handleStreamLine`, but its `state` has already been
196+
// flipped to `"result"` by the time we get here — resolving it below is a
197+
// no-op. Whether THAT still warrants a follow-up request depends on whether
198+
// the model already produced its reply to it in this same stream (e.g. a
199+
// non-`stopAfterTools` tool like `turf_area` that the model calls and then
200+
// immediately answers about in one turn): if so, a follow-up would just
201+
// re-send the same transcript — now including the model's own just-given
202+
// answer — and get back a second, redundant reply. A tool call still in
203+
// `"call"` state (needs real client-side execution) always needs a
204+
// follow-up regardless, since the model hasn't seen any result for it yet.
205+
const hadUnresolvedClientToolCalls = pendingToolCalls.some((inv) => inv.state === "call");
206+
const hadUnresolvedApprovals = pendingApprovals.some(
207+
(inv) => inv.state === "approval-requested",
208+
);
209+
const hasTextReply = ((assistantMsg as Message | null)?.content.length ?? 0) > 0;
210+
const hadServerResolvedToolCallsAwaitingReply =
211+
pendingToolCalls.length > 0 && !hadUnresolvedClientToolCalls && !hasTextReply;
212+
192213
await this.resolveClientToolCalls(pendingToolCalls);
193214
await this.resolveApprovals(pendingApprovals);
194215
const continueForServerResults = await this.resolveServerToolOutcomes(pendingServerResults);
195216

196-
// If there were tool calls or approval decisions to send back, or a host
197-
// reaction to a server-resolved result asked to continue, make another
198-
// request so the model can react (the resolve* calls above guarantee
199-
// every entry here is now resolved).
200-
if (pendingToolCalls.length > 0 || pendingApprovals.length > 0 || continueForServerResults) {
217+
// If there were tool calls or approval decisions that actually needed
218+
// resolving, or a host reaction to a server-resolved result asked to
219+
// continue, make another request so the model can react.
220+
if (
221+
hadUnresolvedClientToolCalls ||
222+
hadUnresolvedApprovals ||
223+
hadServerResolvedToolCallsAwaitingReply ||
224+
continueForServerResults
225+
) {
201226
this.toolCallRound++;
202227
if (this.toolCallRound > this.maxToolCallRounds) {
203228
this.emitError(

packages/chat-element/src/components/MessageItem.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ export function MessageItem({
4141
>
4242
{isError ? "Error" : isUser ? "You" : "Assistant"}
4343
</Typography>
44+
{message.toolInvocations?.map((inv) => (
45+
<ToolCard
46+
key={inv.toolCallId}
47+
invocation={inv}
48+
isPendingApproval={approval?.pendingApprovalToolCallId === inv.toolCallId}
49+
onApprove={approval?.onApprove}
50+
onReject={approval?.onReject}
51+
structuredResult={structuredResultByToolName?.get(inv.toolName)}
52+
mcpApp={mcpAppByToolName?.get(inv.toolName)}
53+
mcpAppApiBase={mcpAppApiBase}
54+
mcpAppSandboxUrl={mcpAppSandboxUrl}
55+
/>
56+
))}
4457
{message.content && (
4558
<Typography
4659
render={<div />}
@@ -58,19 +71,6 @@ export function MessageItem({
5871
)}
5972
</Typography>
6073
)}
61-
{message.toolInvocations?.map((inv) => (
62-
<ToolCard
63-
key={inv.toolCallId}
64-
invocation={inv}
65-
isPendingApproval={approval?.pendingApprovalToolCallId === inv.toolCallId}
66-
onApprove={approval?.onApprove}
67-
onReject={approval?.onReject}
68-
structuredResult={structuredResultByToolName?.get(inv.toolName)}
69-
mcpApp={mcpAppByToolName?.get(inv.toolName)}
70-
mcpAppApiBase={mcpAppApiBase}
71-
mcpAppSandboxUrl={mcpAppSandboxUrl}
72-
/>
73-
))}
7474
</div>
7575
);
7676
}

packages/tools-schemas/src/tools/geoJsonAdd/geoJsonAdd.schema.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,54 @@ import { z } from "zod";
66
* client-side executor. Carries no model-facing description text (see
77
* `geoJsonAdd.ts` and `flyTo.schema.ts` for the convention this follows).
88
*
9-
* `geojson` is deliberately typed loosely (`z.record`, refined to require a
10-
* GeoJSON `type`) rather than a fully-typed Feature/FeatureCollection schema —
11-
* this tool only needs to hand the object to `Cesium.GeoJsonDataSource.load`
12-
* unchanged, and a strict schema would reject legitimate GeoJSON this tool
13-
* has no reason to constrain (arbitrary per-feature `properties`, any
14-
* geometry type, etc.).
9+
* `geojson` is a discriminated-by-`type` union rather than a single
10+
* `.catchall(z.unknown())` object: a bare `{ type: "..." } & catchall(unknown)`
11+
* shape serializes to JSON schema with only `type` listed under `properties`
12+
* (everything else — including `features`/`geometry`/`geometries` — collapses
13+
* into an opaque `additionalProperties: {}`), which models frequently ignore.
14+
* That previously let a model pass e.g. `{"type":"FeatureCollection"}` with no
15+
* `features` array at all, which `Cesium.GeoJsonDataSource.load` then crashes
16+
* on with an opaque `Cannot read properties of undefined (reading 'length')`
17+
* instead of a clear tool `{ error }`. Naming `features`/`geometry`/
18+
* `geometries` as real (still-catchall) properties fixes both the model-facing
19+
* schema and Zod's own validation, same fix already applied to
20+
* `@cesium-ai/turf-tools`' `resolve-geojson.ts`.
1521
*/
22+
const featureShape = z
23+
.object({
24+
type: z.literal("Feature"),
25+
geometry: z
26+
.object({ type: z.string() })
27+
.catchall(z.unknown())
28+
.describe(
29+
"REQUIRED: the feature's GeoJSON geometry, e.g. { type: 'Point', coordinates: [...] }.",
30+
),
31+
})
32+
.catchall(z.unknown())
33+
.describe("A GeoJSON Feature — geometry is required.");
34+
35+
const featureCollectionShape = z
36+
.object({
37+
type: z.literal("FeatureCollection"),
38+
features: z
39+
.array(z.unknown())
40+
.describe("REQUIRED: the array of GeoJSON Feature objects (may be empty)."),
41+
})
42+
.catchall(z.unknown())
43+
.describe("A GeoJSON FeatureCollection — features is required.");
44+
45+
const geometryCollectionShape = z
46+
.object({
47+
type: z.literal("GeometryCollection"),
48+
geometries: z
49+
.array(z.unknown())
50+
.describe("REQUIRED: the array of GeoJSON geometry objects (may be empty)."),
51+
})
52+
.catchall(z.unknown())
53+
.describe("A GeoJSON GeometryCollection — geometries is required.");
54+
1655
export const geoJsonAddInputShape = z.object({
17-
geojson: z
18-
.object({ type: z.enum(["Feature", "FeatureCollection", "GeometryCollection"]) })
19-
.catchall(z.unknown()),
56+
geojson: z.union([featureShape, featureCollectionShape, geometryCollectionShape]),
2057
name: z.string().optional(),
2158
stroke: z.string().optional(),
2259
fill: z.string().optional(),

packages/tools/README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@ const result = await executors.flyTo(viewer, rawArgsFromTheModel);
2828
This package has no logging of its own by default — every executor just resolves a plain `{ success, error? }` result, so a caller that never reads `error` never finds out a tool call failed. Pass a `logger` as `createCesiumToolExecutors`'s second argument to have every executor's outcome (success, a resolved `{ error }`, or a thrown rejection) reported through it:
2929

3030
```ts
31-
import { createCesiumToolExecutors } from "@cesium-ai/tools";
32-
import { createConsoleLogger } from "@cesium-ai/observability";
31+
import { createCesiumToolExecutors, createConsoleToolsLogger } from "@cesium-ai/tools";
3332

34-
const logger = createConsoleLogger({ scope: "cesium-tools", level: "warn" });
35-
const executors = createCesiumToolExecutors({}, logger);
33+
const executors = createCesiumToolExecutors({}, createConsoleToolsLogger("warn"));
3634
```
3735

38-
Pass any `Logger` from `@cesium-ai/observability` (e.g. one backed by your app's OTEL telemetry) to route logging through your own provider.
36+
Implement your own `ToolsLogger` (e.g. backed by an OTEL-wired app logger — see this repo's `frontend/src/tools/cesium-tool-executors.ts` for the worked example) to route this package's logging through your own telemetry instead of `console`.
3937

4038
## Customizing a tool: two mechanisms
4139

@@ -167,6 +165,7 @@ Executors are grouped by domain rather than one file per tool (unlike `@cesium-a
167165
- [`src/utils/cesium-values.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/utils/cesium-values.ts) — small conversions from schema-shaped plain data (a `{longitude, latitude, height?}` position, a CSS color string) into real Cesium types (`Cartesian3`, `Color`, ...).
168166
- [`src/utils/animation-registry.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/utils/animation-registry.ts), [`src/utils/imagery-registry.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/utils/imagery-registry.ts) — per-`Viewer` `WeakMap`-based bookkeeping the animation and imagery tools need (which entity ids/imagery layers this package itself created), so `animationListActive`/`imageryList`/etc. only ever report on state they created.
169167
- [`src/utils/create-entity-add-executor.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/utils/create-entity-add-executor.ts)`createEntityAddExecutor`, the generic validate/build/add/error-handling plumbing every `entityAdd*` tool's own `createXExecutor` (in `entities.ts`) is built from.
168+
- [`src/logger.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/logger.ts)`ToolsLogger`, `noopToolsLogger`, `createConsoleToolsLogger` — see "Logging" above.
170169
- [`src/index.ts`](https://github.com/CesiumGS/cesiumjs-ai-starter-app/blob/main/packages/tools/src/index.ts)`DEFAULT_CESIUM_TOOL_EXECUTORS`, `createCesiumToolExecutors`.
171170

172171
## Exports
@@ -190,3 +189,7 @@ Executors are grouped by domain rather than one file per tool (unlike `@cesium-a
190189
| `createEntityAddExecutor` | The lower-level generic every `createEntityAddXExecutor` above is built from — only needed if you're building a brand-new `entityAdd*`-shaped tool from scratch. |
191190
| `EntityAddExecutorConfig` | Type: an `entityAdd*` factory's config (`shape`, `extendEntityOptions`). |
192191
| `flyTo`, `cameraSetView`, ... | Every individual default executor, exported by name (one per tool). |
192+
| `ToolsLogger` | Type: the console-shaped logger interface (`debug`/`info`/`warn`/`error`) `createCesiumToolExecutors`'s `logger` argument accepts. See "Logging" above. |
193+
| `ToolsLogLevel` | Type: `"debug" \| "info" \| "warn" \| "error" \| "silent"`, accepted by `createConsoleToolsLogger`. |
194+
| `createConsoleToolsLogger` | Builds a `console`-backed `ToolsLogger`, prefixed `[cesium-tools]`, filtered by level (default `"warn"`). |
195+
| `noopToolsLogger` | A `ToolsLogger` whose methods are all no-ops — pass explicitly if you want `createCesiumToolExecutors`'s wrapping without any actual output. |

0 commit comments

Comments
 (0)