@stigmer/react 3.4.1 → 3.5.0
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.
- package/agent/AgentDetailView.d.ts +14 -4
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +73 -11
- package/agent/AgentDetailView.js.map +1 -1
- package/agent/internal/agentToInput.d.ts +3 -0
- package/agent/internal/agentToInput.d.ts.map +1 -1
- package/agent/internal/agentToInput.js +21 -1
- package/agent/internal/agentToInput.js.map +1 -1
- package/api-key/ApiKeyListPanel.d.ts +8 -1
- package/api-key/ApiKeyListPanel.d.ts.map +1 -1
- package/api-key/ApiKeyListPanel.js +9 -7
- package/api-key/ApiKeyListPanel.js.map +1 -1
- package/dependency-graph/DependencyTreeNode.d.ts.map +1 -1
- package/dependency-graph/DependencyTreeNode.js +8 -0
- package/dependency-graph/DependencyTreeNode.js.map +1 -1
- package/dependency-graph/types.d.ts +20 -8
- package/dependency-graph/types.d.ts.map +1 -1
- package/dependency-graph/useDependencyGraph.d.ts.map +1 -1
- package/dependency-graph/useDependencyGraph.js +21 -1
- package/dependency-graph/useDependencyGraph.js.map +1 -1
- package/index.d.ts +2 -0
- package/index.d.ts.map +1 -1
- package/index.js +4 -0
- package/index.js.map +1 -1
- package/internal/scroll-area.d.ts +4 -0
- package/internal/scroll-area.d.ts.map +1 -0
- package/internal/scroll-area.js +15 -0
- package/internal/scroll-area.js.map +1 -0
- package/internal/tooltip.d.ts +7 -0
- package/internal/tooltip.d.ts.map +1 -0
- package/internal/tooltip.js +31 -0
- package/internal/tooltip.js.map +1 -0
- package/mcp-server/StdioSandboxNotice.d.ts +12 -11
- package/mcp-server/StdioSandboxNotice.d.ts.map +1 -1
- package/mcp-server/StdioSandboxNotice.js +13 -12
- package/mcp-server/StdioSandboxNotice.js.map +1 -1
- package/mcp-server/steps/IdentityTransportStep.d.ts.map +1 -1
- package/mcp-server/steps/IdentityTransportStep.js +1 -1
- package/mcp-server/steps/IdentityTransportStep.js.map +1 -1
- package/package.json +4 -4
- package/resource-creation/templates/mcp-server-templates.d.ts.map +1 -1
- package/resource-creation/templates/mcp-server-templates.js +17 -14
- package/resource-creation/templates/mcp-server-templates.js.map +1 -1
- package/sidebar/SettingsSidebar.d.ts +62 -0
- package/sidebar/SettingsSidebar.d.ts.map +1 -0
- package/sidebar/SettingsSidebar.js +60 -0
- package/sidebar/SettingsSidebar.js.map +1 -0
- package/sidebar/WorkspaceSidebar.d.ts +87 -0
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -0
- package/sidebar/WorkspaceSidebar.js +95 -0
- package/sidebar/WorkspaceSidebar.js.map +1 -0
- package/sidebar/chrome.d.ts +48 -0
- package/sidebar/chrome.d.ts.map +1 -0
- package/sidebar/chrome.js +32 -0
- package/sidebar/chrome.js.map +1 -0
- package/sidebar/index.d.ts +6 -0
- package/sidebar/index.d.ts.map +1 -0
- package/sidebar/index.js +9 -0
- package/sidebar/index.js.map +1 -0
- package/sidebar/types.d.ts +54 -0
- package/sidebar/types.d.ts.map +1 -0
- package/sidebar/types.js +2 -0
- package/sidebar/types.js.map +1 -0
- package/src/agent/AgentDetailView.tsx +198 -11
- package/src/agent/__tests__/AgentDetailView.datastores.test.tsx +166 -0
- package/src/agent/internal/__tests__/agentToInput.test.ts +154 -0
- package/src/agent/internal/agentToInput.ts +25 -7
- package/src/api-key/ApiKeyListPanel.tsx +17 -4
- package/src/dependency-graph/DependencyTreeNode.tsx +8 -0
- package/src/dependency-graph/__tests__/useDependencyGraph.test.ts +108 -0
- package/src/dependency-graph/types.ts +20 -8
- package/src/dependency-graph/useDependencyGraph.ts +23 -1
- package/src/index.ts +13 -0
- package/src/internal/scroll-area.tsx +37 -0
- package/src/internal/tooltip.tsx +66 -0
- package/src/mcp-server/StdioSandboxNotice.tsx +16 -14
- package/src/mcp-server/__tests__/StdioSandboxNotice.test.tsx +3 -2
- package/src/mcp-server/steps/IdentityTransportStep.tsx +7 -0
- package/src/resource-creation/templates/mcp-server-templates.ts +17 -14
- package/src/sidebar/SettingsSidebar.tsx +173 -0
- package/src/sidebar/WorkspaceSidebar.tsx +386 -0
- package/src/sidebar/__tests__/SettingsSidebar.test.tsx +107 -0
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +236 -0
- package/src/sidebar/chrome.tsx +129 -0
- package/src/sidebar/index.ts +16 -0
- package/src/sidebar/types.ts +55 -0
- package/src/test/__tests__/samples.test.ts +2 -0
- package/src/test/samples.ts +48 -0
- package/styles.css +1 -1
- package/test/samples.d.ts +21 -0
- package/test/samples.d.ts.map +1 -1
- package/test/samples.js +32 -0
- package/test/samples.js.map +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Tests for the Datastores section on AgentDetailView (stigmer/stigmer#319):
|
|
2
|
+
// read-mode rendering and navigation, the inline-edit save payload (which
|
|
3
|
+
// must round-trip the full spec through agentToInput), and the DD-006
|
|
4
|
+
// surfacing of the backend's datastore exposure guard refusal.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeAll, afterEach, beforeEach, type Mock } from "vitest";
|
|
7
|
+
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
|
8
|
+
import userEvent from "@testing-library/user-event";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { create } from "@bufbuild/protobuf";
|
|
11
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
12
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
13
|
+
import type { AgentInput } from "@stigmer/sdk";
|
|
14
|
+
import { StigmerContext } from "../../context";
|
|
15
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
16
|
+
import { AgentDetailView } from "../AgentDetailView";
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
20
|
+
this.open = true;
|
|
21
|
+
};
|
|
22
|
+
HTMLDialogElement.prototype.close = function close() {
|
|
23
|
+
this.open = false;
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => cleanup());
|
|
28
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
29
|
+
|
|
30
|
+
function makeAgent() {
|
|
31
|
+
return create(AgentSchema, {
|
|
32
|
+
metadata: {
|
|
33
|
+
id: "agt_1",
|
|
34
|
+
name: "Clinic Assistant",
|
|
35
|
+
slug: "clinic-assistant",
|
|
36
|
+
org: "acme",
|
|
37
|
+
},
|
|
38
|
+
spec: {
|
|
39
|
+
instructions: "You are a careful clinic assistant.",
|
|
40
|
+
datastoreUsages: [
|
|
41
|
+
{
|
|
42
|
+
datastoreRef: {
|
|
43
|
+
org: "acme",
|
|
44
|
+
slug: "clinic-records",
|
|
45
|
+
kind: ApiResourceKind.datastore,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
datastoreRef: {
|
|
50
|
+
org: "partner",
|
|
51
|
+
slug: "shared-records",
|
|
52
|
+
kind: ApiResourceKind.datastore,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderView(overrides: {
|
|
61
|
+
update?: Mock;
|
|
62
|
+
onDatastoreClick?: (ref: { org: string; slug: string }) => void;
|
|
63
|
+
editable?: boolean;
|
|
64
|
+
}) {
|
|
65
|
+
const agent = makeAgent();
|
|
66
|
+
const update = overrides.update ?? vi.fn().mockResolvedValue(agent);
|
|
67
|
+
const stigmer = {
|
|
68
|
+
agent: {
|
|
69
|
+
getByReference: vi.fn().mockResolvedValue(agent),
|
|
70
|
+
update,
|
|
71
|
+
},
|
|
72
|
+
iamPolicy: {
|
|
73
|
+
checkMyPermission: vi.fn().mockResolvedValue({ isAuthorized: false }),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function Wrapper({ children }: { children: ReactNode }) {
|
|
78
|
+
return (
|
|
79
|
+
<FetchCacheContext.Provider value={null}>
|
|
80
|
+
<StigmerContext.Provider value={stigmer as never}>
|
|
81
|
+
{children}
|
|
82
|
+
</StigmerContext.Provider>
|
|
83
|
+
</FetchCacheContext.Provider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = render(
|
|
88
|
+
<AgentDetailView
|
|
89
|
+
org="acme"
|
|
90
|
+
slug="clinic-assistant"
|
|
91
|
+
editable={overrides.editable}
|
|
92
|
+
onDatastoreClick={overrides.onDatastoreClick}
|
|
93
|
+
/>,
|
|
94
|
+
{ wrapper: Wrapper },
|
|
95
|
+
);
|
|
96
|
+
return { ...result, update };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("AgentDetailView — Datastores section (read mode)", () => {
|
|
100
|
+
it("renders attached datastores with org-qualified labels for cross-org refs", async () => {
|
|
101
|
+
renderView({});
|
|
102
|
+
|
|
103
|
+
expect(await screen.findByText("Datastores")).toBeTruthy();
|
|
104
|
+
expect(screen.getByText("clinic-records")).toBeTruthy();
|
|
105
|
+
expect(screen.getByText("partner/shared-records")).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fires onDatastoreClick with the resolved org and slug", async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
const onDatastoreClick = vi.fn();
|
|
111
|
+
renderView({ onDatastoreClick });
|
|
112
|
+
|
|
113
|
+
await user.click(await screen.findByText("clinic-records"));
|
|
114
|
+
expect(onDatastoreClick).toHaveBeenCalledWith({
|
|
115
|
+
org: "acme",
|
|
116
|
+
slug: "clinic-records",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await user.click(screen.getByText("partner/shared-records"));
|
|
120
|
+
expect(onDatastoreClick).toHaveBeenCalledWith({
|
|
121
|
+
org: "partner",
|
|
122
|
+
slug: "shared-records",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("AgentDetailView — Datastores section (inline edit)", () => {
|
|
128
|
+
it("saves the full reconstructed input with correctly-shaped datastoreUsages", async () => {
|
|
129
|
+
const user = userEvent.setup();
|
|
130
|
+
const { update } = renderView({ editable: true });
|
|
131
|
+
|
|
132
|
+
await user.click(await screen.findByLabelText("Edit datastores"));
|
|
133
|
+
await user.click(screen.getByLabelText("Remove partner/shared-records"));
|
|
134
|
+
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
|
|
137
|
+
const input = update.mock.calls[0][0] as AgentInput;
|
|
138
|
+
|
|
139
|
+
expect(input.datastoreUsages).toEqual([
|
|
140
|
+
{ datastoreRef: { org: "acme", slug: "clinic-records" } },
|
|
141
|
+
]);
|
|
142
|
+
// The rest of the spec must ride along — a section save is a full
|
|
143
|
+
// spec replacement reconstructed via agentToInput().
|
|
144
|
+
expect(input.instructions).toBe("You are a careful clinic assistant.");
|
|
145
|
+
expect(input.org).toBe("acme");
|
|
146
|
+
expect(input.slug).toBe("clinic-assistant");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("surfaces the backend's exposure-guard refusal in the section editor (DD-006)", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
const guardMessage =
|
|
152
|
+
'agent "clinic-assistant" cannot be public while it uses datastores ' +
|
|
153
|
+
"(clinic-records): multi-tenant datastore sharing is not supported — " +
|
|
154
|
+
"keep the agent private or org-visible, or remove its datastore_usages";
|
|
155
|
+
renderView({
|
|
156
|
+
editable: true,
|
|
157
|
+
update: vi.fn().mockRejectedValue(new Error(guardMessage)),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await user.click(await screen.findByLabelText("Edit datastores"));
|
|
161
|
+
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
|
162
|
+
|
|
163
|
+
const alert = await screen.findByRole("alert");
|
|
164
|
+
expect(alert.textContent).toContain(guardMessage);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { create, equals, isFieldSet, toJson } from "@bufbuild/protobuf";
|
|
3
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
4
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
5
|
+
import { AgentSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
6
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
7
|
+
import { buildAgentProto } from "@stigmer/sdk";
|
|
8
|
+
import { agentToInput } from "../agentToInput";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression guard for stigmer/stigmer#319 — the console agent editor
|
|
12
|
+
* silently deleting `datastore_usages` on save.
|
|
13
|
+
*
|
|
14
|
+
* `agentToInput()` reconstructs a full `AgentInput` from a fetched Agent
|
|
15
|
+
* so inline edits can re-submit the whole spec (the backend does full
|
|
16
|
+
* spec replacement). Any spec field the converter does not map is
|
|
17
|
+
* therefore *cleared* by the next console edit. Two tests enforce the
|
|
18
|
+
* converter's "must be kept exhaustive" contract:
|
|
19
|
+
*
|
|
20
|
+
* 1. The round-trip test proves no field of the fixture below survives
|
|
21
|
+
* conversion distorted or dropped.
|
|
22
|
+
* 2. The tripwire test proves the fixture itself covers every
|
|
23
|
+
* `AgentSpec` field, via schema reflection. When a new proto field
|
|
24
|
+
* lands, the tripwire fails until the fixture sets it — and the
|
|
25
|
+
* round-trip then fails until `agentToInput()` maps it.
|
|
26
|
+
*
|
|
27
|
+
* Nested messages are covered by population, not reflection: every
|
|
28
|
+
* sub-field of the fixture (ref `version` pins, approval overrides,
|
|
29
|
+
* sub-agent grants, env metadata) must carry a non-default value so the
|
|
30
|
+
* round-trip comparison can catch its loss.
|
|
31
|
+
*/
|
|
32
|
+
function fullyPopulatedAgent(): Agent {
|
|
33
|
+
return create(AgentSchema, {
|
|
34
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
35
|
+
kind: "Agent",
|
|
36
|
+
metadata: {
|
|
37
|
+
id: "agt_test",
|
|
38
|
+
name: "Clinic Assistant",
|
|
39
|
+
slug: "clinic-assistant",
|
|
40
|
+
org: "acme",
|
|
41
|
+
labels: { team: "care-ops" },
|
|
42
|
+
},
|
|
43
|
+
spec: {
|
|
44
|
+
description: "Front-desk assistant for the clinic pilot.",
|
|
45
|
+
iconUrl: "https://example.com/clinic.svg",
|
|
46
|
+
instructions: "You are a careful clinic assistant. Verify identity first.",
|
|
47
|
+
mcpServerUsages: [
|
|
48
|
+
{
|
|
49
|
+
mcpServerRef: {
|
|
50
|
+
org: "acme",
|
|
51
|
+
slug: "github",
|
|
52
|
+
kind: ApiResourceKind.mcp_server,
|
|
53
|
+
},
|
|
54
|
+
enabledTools: ["create_issue", "search_issues"],
|
|
55
|
+
toolApprovalOverrides: [
|
|
56
|
+
{
|
|
57
|
+
toolName: "create_issue",
|
|
58
|
+
requiresApproval: true,
|
|
59
|
+
message: "Create issue {{args.title}}?",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
skillRefs: [
|
|
65
|
+
{
|
|
66
|
+
org: "acme",
|
|
67
|
+
slug: "triage-guide",
|
|
68
|
+
kind: ApiResourceKind.skill,
|
|
69
|
+
// The version pin (tag or hash) must survive conversion — a
|
|
70
|
+
// dropped pin silently resets the skill to "latest".
|
|
71
|
+
version: "stable",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
subAgents: [
|
|
75
|
+
{
|
|
76
|
+
name: "researcher",
|
|
77
|
+
description: "Looks up prior cases",
|
|
78
|
+
instructions: "Research thoroughly before answering anything.",
|
|
79
|
+
mcpAccess: [{ mcpServer: "github", enabledTools: ["search_issues"] }],
|
|
80
|
+
skillRefs: [
|
|
81
|
+
{
|
|
82
|
+
org: "acme",
|
|
83
|
+
slug: "research-methods",
|
|
84
|
+
kind: ApiResourceKind.skill,
|
|
85
|
+
version: "v1.2",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
modelOverride: "claude-sonnet",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
env: {
|
|
92
|
+
CLINIC_API_KEY: {
|
|
93
|
+
isSecret: true,
|
|
94
|
+
description: "Records bridge credential",
|
|
95
|
+
},
|
|
96
|
+
REGION: { optional: true },
|
|
97
|
+
},
|
|
98
|
+
datastoreUsages: [
|
|
99
|
+
{
|
|
100
|
+
datastoreRef: {
|
|
101
|
+
org: "acme",
|
|
102
|
+
slug: "clinic-records",
|
|
103
|
+
kind: ApiResourceKind.datastore,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("agentToInput", () => {
|
|
112
|
+
it("round-trips a fully-populated spec through AgentInput without loss", () => {
|
|
113
|
+
const agent = fullyPopulatedAgent();
|
|
114
|
+
|
|
115
|
+
const rebuilt = buildAgentProto(agentToInput(agent));
|
|
116
|
+
|
|
117
|
+
// toJson gives a readable structural diff on failure; the protobuf
|
|
118
|
+
// equals() assertion below is the authoritative semantic check.
|
|
119
|
+
expect(toJson(AgentSpecSchema, rebuilt.spec!)).toEqual(
|
|
120
|
+
toJson(AgentSpecSchema, agent.spec!),
|
|
121
|
+
);
|
|
122
|
+
expect(equals(AgentSpecSchema, rebuilt.spec!, agent.spec!)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("round-trips the input-owned metadata fields", () => {
|
|
126
|
+
const agent = fullyPopulatedAgent();
|
|
127
|
+
|
|
128
|
+
const rebuilt = buildAgentProto(agentToInput(agent));
|
|
129
|
+
|
|
130
|
+
// Only the fields AgentInput owns: id is server-assigned, and
|
|
131
|
+
// visibility is deliberately unmapped (the backend preserves it on
|
|
132
|
+
// omit — see preserveImmutableFields in the update pipeline).
|
|
133
|
+
expect(rebuilt.metadata?.name).toBe(agent.metadata?.name);
|
|
134
|
+
expect(rebuilt.metadata?.org).toBe(agent.metadata?.org);
|
|
135
|
+
expect(rebuilt.metadata?.slug).toBe(agent.metadata?.slug);
|
|
136
|
+
expect(rebuilt.metadata?.labels).toEqual(agent.metadata?.labels);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("populates every AgentSpec field in the fixture (new-field tripwire)", () => {
|
|
140
|
+
const spec = fullyPopulatedAgent().spec!;
|
|
141
|
+
|
|
142
|
+
const unsetFields = AgentSpecSchema.fields
|
|
143
|
+
.filter((field) => !isFieldSet(spec, field))
|
|
144
|
+
.map((field) => field.name);
|
|
145
|
+
|
|
146
|
+
expect(
|
|
147
|
+
unsetFields,
|
|
148
|
+
`AgentSpec gained field(s) [${unsetFields.join(", ")}] that the ` +
|
|
149
|
+
"fixture does not populate. Set them in fullyPopulatedAgent() AND " +
|
|
150
|
+
"map them in agentToInput() — an unmapped spec field is cleared " +
|
|
151
|
+
"by every console inline edit (stigmer/stigmer#319).",
|
|
152
|
+
).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
2
2
|
import type {
|
|
3
3
|
AgentInput,
|
|
4
|
+
DatastoreUsageInput,
|
|
5
|
+
EnvVarDeclarationInput,
|
|
4
6
|
McpServerUsageInput,
|
|
5
7
|
SubAgentInput,
|
|
6
8
|
} from "@stigmer/sdk";
|
|
7
9
|
|
|
8
|
-
interface EnvVarDeclarationInput {
|
|
9
|
-
isSecret?: boolean;
|
|
10
|
-
description?: string;
|
|
11
|
-
optional?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
10
|
/**
|
|
15
11
|
* Converts a fetched Agent proto to the AgentInput shape expected by
|
|
16
12
|
* `stigmer.agent.update()`. This enables inline field editing: read the
|
|
@@ -18,16 +14,23 @@ interface EnvVarDeclarationInput {
|
|
|
18
14
|
*
|
|
19
15
|
* Must be kept exhaustive — any spec field not mapped here will be
|
|
20
16
|
* cleared on the next update (the backend does full spec replacement).
|
|
17
|
+
* Enforced by `__tests__/agentToInput.test.ts`: a schema tripwire fails
|
|
18
|
+
* when `AgentSpec` gains a field this converter does not round-trip
|
|
19
|
+
* (stigmer/stigmer#319 was exactly such a drop).
|
|
21
20
|
*/
|
|
22
21
|
export function agentToInput(agent: Agent): AgentInput {
|
|
23
22
|
const meta = agent.metadata;
|
|
24
23
|
const spec = agent.spec;
|
|
25
24
|
|
|
25
|
+
// Resource refs preserve `version` (the tag/hash pin on versioned
|
|
26
|
+
// resources like Skills) — dropping it would silently reset a pinned
|
|
27
|
+
// reference to "latest" on the next inline edit.
|
|
26
28
|
const mcpServerUsages: McpServerUsageInput[] | undefined =
|
|
27
29
|
spec?.mcpServerUsages?.map((usage) => ({
|
|
28
30
|
mcpServerRef: {
|
|
29
31
|
org: usage.mcpServerRef?.org ?? "",
|
|
30
32
|
slug: usage.mcpServerRef?.slug ?? "",
|
|
33
|
+
version: usage.mcpServerRef?.version || undefined,
|
|
31
34
|
},
|
|
32
35
|
enabledTools: usage.enabledTools.length > 0 ? [...usage.enabledTools] : undefined,
|
|
33
36
|
toolApprovalOverrides:
|
|
@@ -44,6 +47,7 @@ export function agentToInput(agent: Agent): AgentInput {
|
|
|
44
47
|
spec?.skillRefs?.map((ref) => ({
|
|
45
48
|
org: ref.org || "",
|
|
46
49
|
slug: ref.slug,
|
|
50
|
+
version: ref.version || undefined,
|
|
47
51
|
}));
|
|
48
52
|
|
|
49
53
|
const subAgents: SubAgentInput[] | undefined =
|
|
@@ -60,11 +64,24 @@ export function agentToInput(agent: Agent): AgentInput {
|
|
|
60
64
|
: undefined,
|
|
61
65
|
skillRefs:
|
|
62
66
|
sa.skillRefs.length > 0
|
|
63
|
-
? sa.skillRefs.map((r) => ({
|
|
67
|
+
? sa.skillRefs.map((r) => ({
|
|
68
|
+
org: r.org || "",
|
|
69
|
+
slug: r.slug,
|
|
70
|
+
version: r.version || undefined,
|
|
71
|
+
}))
|
|
64
72
|
: undefined,
|
|
65
73
|
modelOverride: sa.modelOverride || undefined,
|
|
66
74
|
}));
|
|
67
75
|
|
|
76
|
+
const datastoreUsages: DatastoreUsageInput[] | undefined =
|
|
77
|
+
spec?.datastoreUsages?.map((usage) => ({
|
|
78
|
+
datastoreRef: {
|
|
79
|
+
org: usage.datastoreRef?.org ?? "",
|
|
80
|
+
slug: usage.datastoreRef?.slug ?? "",
|
|
81
|
+
version: usage.datastoreRef?.version || undefined,
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
|
|
68
85
|
let env: Record<string, EnvVarDeclarationInput> | undefined;
|
|
69
86
|
if (spec?.env && Object.keys(spec.env).length > 0) {
|
|
70
87
|
env = {};
|
|
@@ -91,5 +108,6 @@ export function agentToInput(agent: Agent): AgentInput {
|
|
|
91
108
|
skillRefs: skillRefs?.length ? skillRefs : undefined,
|
|
92
109
|
subAgents: subAgents?.length ? subAgents : undefined,
|
|
93
110
|
env,
|
|
111
|
+
datastoreUsages: datastoreUsages?.length ? datastoreUsages : undefined,
|
|
94
112
|
};
|
|
95
113
|
}
|
|
@@ -18,6 +18,13 @@ export interface ApiKeyListPanelProps {
|
|
|
18
18
|
readonly onRefetchRef?: (refetch: () => void) => void;
|
|
19
19
|
/** Additional CSS class names for the root container. */
|
|
20
20
|
readonly className?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Reference instant for the "last used" relative stamp. Defaults to
|
|
23
|
+
* the live clock. **Deterministic hosts (documentation embeds, video
|
|
24
|
+
* export) must pass a frozen instant** — otherwise a depicted key's
|
|
25
|
+
* "2h ago" drifts with every replay.
|
|
26
|
+
*/
|
|
27
|
+
readonly now?: Date;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -46,6 +53,7 @@ export interface ApiKeyListPanelProps {
|
|
|
46
53
|
export function ApiKeyListPanel({
|
|
47
54
|
onRefetchRef,
|
|
48
55
|
className,
|
|
56
|
+
now,
|
|
49
57
|
}: ApiKeyListPanelProps) {
|
|
50
58
|
const { apiKeys, isLoading, error, refetch } = useApiKeyList();
|
|
51
59
|
const [confirmingId, setConfirmingId] = useState<string | null>(null);
|
|
@@ -104,6 +112,7 @@ export function ApiKeyListPanel({
|
|
|
104
112
|
<ApiKeyRow
|
|
105
113
|
key={id}
|
|
106
114
|
apiKey={key}
|
|
115
|
+
now={now}
|
|
107
116
|
isConfirming={confirmingId === id}
|
|
108
117
|
onConfirmDelete={() => setConfirmingId(id)}
|
|
109
118
|
onCancelDelete={() => setConfirmingId(null)}
|
|
@@ -124,12 +133,14 @@ export function ApiKeyListPanel({
|
|
|
124
133
|
|
|
125
134
|
function ApiKeyRow({
|
|
126
135
|
apiKey,
|
|
136
|
+
now,
|
|
127
137
|
isConfirming,
|
|
128
138
|
onConfirmDelete,
|
|
129
139
|
onCancelDelete,
|
|
130
140
|
onDeleted,
|
|
131
141
|
}: {
|
|
132
142
|
apiKey: ApiKey;
|
|
143
|
+
now?: Date;
|
|
133
144
|
isConfirming: boolean;
|
|
134
145
|
onConfirmDelete: () => void;
|
|
135
146
|
onCancelDelete: () => void;
|
|
@@ -243,7 +254,9 @@ function ApiKeyRow({
|
|
|
243
254
|
: "No expiry"}
|
|
244
255
|
</span>
|
|
245
256
|
<span>
|
|
246
|
-
{lastUsedAt
|
|
257
|
+
{lastUsedAt
|
|
258
|
+
? formatRelativeTime(timestampDate(lastUsedAt), now)
|
|
259
|
+
: "Never used"}
|
|
247
260
|
</span>
|
|
248
261
|
</div>
|
|
249
262
|
|
|
@@ -276,9 +289,9 @@ function formatShortDate(date: Date): string {
|
|
|
276
289
|
});
|
|
277
290
|
}
|
|
278
291
|
|
|
279
|
-
function formatRelativeTime(date: Date): string {
|
|
280
|
-
const
|
|
281
|
-
const diffMs =
|
|
292
|
+
function formatRelativeTime(date: Date, now?: Date): string {
|
|
293
|
+
const ref = now?.getTime() ?? Date.now();
|
|
294
|
+
const diffMs = ref - date.getTime();
|
|
282
295
|
|
|
283
296
|
if (diffMs < 0) return "Just now";
|
|
284
297
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useState, type KeyboardEvent } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { DatastoreIcon } from "../datastore/DatastoreDetailView.js";
|
|
5
6
|
import type { DependencyNode, NodeKind } from "./types.js";
|
|
6
7
|
|
|
7
8
|
interface DependencyTreeNodeProps {
|
|
@@ -15,6 +16,7 @@ const KIND_LABELS: Record<NodeKind, string> = {
|
|
|
15
16
|
agent: "Agent",
|
|
16
17
|
"mcp-server": "MCP",
|
|
17
18
|
skill: "Skill",
|
|
19
|
+
datastore: "Datastore",
|
|
18
20
|
"sub-agent": "Sub-Agent",
|
|
19
21
|
};
|
|
20
22
|
|
|
@@ -179,6 +181,10 @@ function kindBadgeClasses(kind: NodeKind): string {
|
|
|
179
181
|
return "bg-[var(--stgm-status-running-subtle)] text-[var(--stgm-status-running)]";
|
|
180
182
|
case "skill":
|
|
181
183
|
return "bg-[var(--stgm-status-pending-subtle)] text-[var(--stgm-status-pending)]";
|
|
184
|
+
// Badges borrow status hues as category colors (skill=pending,
|
|
185
|
+
// sub-agent=ready); datastore takes the remaining distinct hue.
|
|
186
|
+
case "datastore":
|
|
187
|
+
return "bg-[var(--stgm-status-degraded-subtle)] text-[var(--stgm-status-degraded)]";
|
|
182
188
|
case "sub-agent":
|
|
183
189
|
return "bg-[var(--stgm-status-ready-subtle)] text-[var(--stgm-status-ready)]";
|
|
184
190
|
case "agent":
|
|
@@ -205,6 +211,8 @@ function NodeIcon({
|
|
|
205
211
|
return <McpServerIcon className={className} />;
|
|
206
212
|
case "skill":
|
|
207
213
|
return <SkillIcon className={className} />;
|
|
214
|
+
case "datastore":
|
|
215
|
+
return <DatastoreIcon className={className} />;
|
|
208
216
|
}
|
|
209
217
|
}
|
|
210
218
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { useDependencyGraph } from "../useDependencyGraph";
|
|
4
|
+
|
|
5
|
+
const emptySpec = {
|
|
6
|
+
mcpServerUsages: [],
|
|
7
|
+
skillRefs: [],
|
|
8
|
+
subAgents: [],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function graphFor(spec: Parameters<typeof useDependencyGraph>[0]["spec"]) {
|
|
12
|
+
const { result } = renderHook(() =>
|
|
13
|
+
useDependencyGraph({ agentName: "clinic-assistant", agentOrg: "acme", spec }),
|
|
14
|
+
);
|
|
15
|
+
return result.current;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("useDependencyGraph — datastore nodes", () => {
|
|
19
|
+
it("derives a datastore node with a navigation ref", () => {
|
|
20
|
+
const { tree, isEmpty } = graphFor({
|
|
21
|
+
...emptySpec,
|
|
22
|
+
datastoreUsages: [
|
|
23
|
+
{ datastoreRef: { org: "acme", slug: "clinic-records" } },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// A datastore-only agent has dependencies — the tab must appear.
|
|
28
|
+
expect(isEmpty).toBe(false);
|
|
29
|
+
expect(tree).not.toBeNull();
|
|
30
|
+
expect(tree!.nodeCount).toBe(2);
|
|
31
|
+
|
|
32
|
+
const node = tree!.root.children[0];
|
|
33
|
+
expect(node).toMatchObject({
|
|
34
|
+
id: "datastore:clinic-records",
|
|
35
|
+
kind: "datastore",
|
|
36
|
+
label: "clinic-records",
|
|
37
|
+
ref: { org: "acme", slug: "clinic-records" },
|
|
38
|
+
});
|
|
39
|
+
// Same-org ref gets no qualified label.
|
|
40
|
+
expect(node.qualifiedLabel).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("qualifies cross-org datastore labels and falls back to the agent org for empty refs", () => {
|
|
44
|
+
const { tree } = graphFor({
|
|
45
|
+
...emptySpec,
|
|
46
|
+
datastoreUsages: [
|
|
47
|
+
{ datastoreRef: { org: "partner", slug: "shared-records" } },
|
|
48
|
+
{ datastoreRef: { org: "", slug: "local-records" } },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [crossOrg, relative] = tree!.root.children;
|
|
53
|
+
expect(crossOrg.qualifiedLabel).toBe("partner/shared-records");
|
|
54
|
+
expect(crossOrg.ref).toEqual({ org: "partner", slug: "shared-records" });
|
|
55
|
+
expect(relative.qualifiedLabel).toBeUndefined();
|
|
56
|
+
expect(relative.ref).toEqual({ org: "acme", slug: "local-records" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("orders children to mirror the Overview sections: MCP, skills, datastores, sub-agents", () => {
|
|
60
|
+
const { tree } = graphFor({
|
|
61
|
+
mcpServerUsages: [
|
|
62
|
+
{
|
|
63
|
+
mcpServerRef: { org: "acme", slug: "github" },
|
|
64
|
+
enabledTools: [],
|
|
65
|
+
toolApprovalOverrides: [],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
skillRefs: [{ org: "acme", slug: "triage-guide" }],
|
|
69
|
+
subAgents: [
|
|
70
|
+
{
|
|
71
|
+
name: "researcher",
|
|
72
|
+
description: "",
|
|
73
|
+
mcpAccess: [],
|
|
74
|
+
skillRefs: [],
|
|
75
|
+
modelOverride: "",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
datastoreUsages: [
|
|
79
|
+
{ datastoreRef: { org: "acme", slug: "clinic-records" } },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(tree!.root.children.map((c) => c.kind)).toEqual([
|
|
84
|
+
"mcp-server",
|
|
85
|
+
"skill",
|
|
86
|
+
"datastore",
|
|
87
|
+
"sub-agent",
|
|
88
|
+
]);
|
|
89
|
+
expect(tree!.nodeCount).toBe(5);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("accepts specs without the datastoreUsages field (pre-existing public callers)", () => {
|
|
93
|
+
const { tree, isEmpty } = graphFor({
|
|
94
|
+
...emptySpec,
|
|
95
|
+
skillRefs: [{ org: "acme", slug: "triage-guide" }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(isEmpty).toBe(false);
|
|
99
|
+
expect(tree!.root.children.map((c) => c.kind)).toEqual(["skill"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("stays empty when no dependencies exist at all", () => {
|
|
103
|
+
const { tree, isEmpty } = graphFor({ ...emptySpec, datastoreUsages: [] });
|
|
104
|
+
|
|
105
|
+
expect(isEmpty).toBe(true);
|
|
106
|
+
expect(tree).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* - `"agent"` — the root agent (always the tree root)
|
|
5
5
|
* - `"mcp-server"` — an MCP server referenced via `mcpServerUsages` or `mcpAccess`
|
|
6
6
|
* - `"skill"` — a skill referenced via `skillRefs`
|
|
7
|
+
* - `"datastore"` — a datastore referenced via `datastoreUsages`
|
|
7
8
|
* - `"sub-agent"` — an inline sub-agent definition with its own dependencies
|
|
8
9
|
*/
|
|
9
|
-
export type NodeKind = "agent" | "mcp-server" | "skill" | "sub-agent";
|
|
10
|
+
export type NodeKind = "agent" | "mcp-server" | "skill" | "datastore" | "sub-agent";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* A single node in the agent dependency tree.
|
|
@@ -15,9 +16,9 @@ export type NodeKind = "agent" | "mcp-server" | "skill" | "sub-agent";
|
|
|
15
16
|
* (MCP servers and skills they access). Edges are implicit in the
|
|
16
17
|
* parent-child relationship — no separate edge type is needed.
|
|
17
18
|
*
|
|
18
|
-
* Nodes are navigable when `ref` is defined (MCP servers, skills
|
|
19
|
-
* Sub-agent nodes have no `ref` because they are inline
|
|
20
|
-
* within the agent spec, not standalone resources.
|
|
19
|
+
* Nodes are navigable when `ref` is defined (MCP servers, skills,
|
|
20
|
+
* datastores). Sub-agent nodes have no `ref` because they are inline
|
|
21
|
+
* definitions within the agent spec, not standalone resources.
|
|
21
22
|
*/
|
|
22
23
|
export interface DependencyNode {
|
|
23
24
|
/**
|
|
@@ -46,9 +47,9 @@ export interface DependencyNode {
|
|
|
46
47
|
/** Child nodes. Empty array for leaf nodes (MCP servers, skills). */
|
|
47
48
|
readonly children: readonly DependencyNode[];
|
|
48
49
|
/**
|
|
49
|
-
* Navigation reference for clickable nodes. Defined for MCP servers
|
|
50
|
-
* and
|
|
51
|
-
* definitions) and the root agent.
|
|
50
|
+
* Navigation reference for clickable nodes. Defined for MCP servers,
|
|
51
|
+
* skills, and datastores (standalone resources). Undefined for
|
|
52
|
+
* sub-agents (inline definitions) and the root agent.
|
|
52
53
|
*/
|
|
53
54
|
readonly ref?: { readonly org: string; readonly slug: string };
|
|
54
55
|
}
|
|
@@ -70,7 +71,7 @@ export interface DependencyGraphProps {
|
|
|
70
71
|
/**
|
|
71
72
|
* Called when a navigable node is clicked. The `node.ref` field
|
|
72
73
|
* contains the `org` and `slug` for routing. Only fired for nodes
|
|
73
|
-
* where `ref` is defined (MCP servers, skills).
|
|
74
|
+
* where `ref` is defined (MCP servers, skills, datastores).
|
|
74
75
|
*/
|
|
75
76
|
readonly onNodeClick?: (node: DependencyNode) => void;
|
|
76
77
|
/**
|
|
@@ -119,6 +120,17 @@ export interface UseDependencyGraphOptions {
|
|
|
119
120
|
}[];
|
|
120
121
|
readonly modelOverride: string;
|
|
121
122
|
}[];
|
|
123
|
+
/**
|
|
124
|
+
* Datastore attachments (`spec.datastoreUsages`). Optional because
|
|
125
|
+
* this hook is a public SDK export: callers passing hand-built spec
|
|
126
|
+
* objects written before the field existed must keep compiling.
|
|
127
|
+
*/
|
|
128
|
+
readonly datastoreUsages?: readonly {
|
|
129
|
+
readonly datastoreRef?: {
|
|
130
|
+
readonly org: string;
|
|
131
|
+
readonly slug: string;
|
|
132
|
+
};
|
|
133
|
+
}[];
|
|
122
134
|
} | undefined;
|
|
123
135
|
}
|
|
124
136
|
|