Skip to content

Commit 89e2573

Browse files
committed
fix: cubic feedback for workflow files
1 parent 236f139 commit 89e2573

File tree

7 files changed

+200
-68
lines changed

7 files changed

+200
-68
lines changed

extensions/cli/src/hooks/useService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function useServices<T extends Record<string, any>>(
123123
hasError,
124124
services,
125125
};
126-
}, [serviceNames]);
126+
}, [serviceNames, container]);
127127

128128
const [loading, setLoading] = useState(true);
129129
const [error, setError] = useState<Error | null>(null);

extensions/cli/src/services/workflow-integration.test.ts

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ describe("Workflow Integration Tests", () => {
379379

380380
const baseConfig = {
381381
rules: ["existing rule"],
382+
prompts: [],
382383
};
383384

384385
const enhancedConfig = await configEnhancer.enhanceConfig(
@@ -387,34 +388,42 @@ describe("Workflow Integration Tests", () => {
387388
workflowService.getState(),
388389
);
389390

390-
// Prompts should be processed at runtime, not injected into config
391-
// The workflow prompt is added to options.prompt and will be processed
392-
// by processAndCombinePrompts() in chat.ts
391+
// Workflow prompt should be added to config.prompts
392+
expect(enhancedConfig.prompts).toBeDefined();
393+
expect(enhancedConfig.prompts?.length).toBeGreaterThan(0);
394+
expect(enhancedConfig.prompts?.[0]).toMatchObject({
395+
prompt: "You are a workflow assistant.",
396+
name: expect.stringContaining("Test Workflow"),
397+
});
393398
expect(enhancedConfig.rules).toHaveLength(2);
394399
});
395400

396-
it("should add workflow prompt as prefix to existing prompts", async () => {
401+
it("should add workflow prompt to config alongside other prompts", async () => {
397402
// Setup workflow service with active workflow
398403
mockLoadPackageFromHub.mockResolvedValue(mockWorkflowFile);
399404
await workflowService.initialize("owner/workflow");
400405

401-
// Test that the workflow prompt gets added to options.prompt as prefix
402-
const baseOptions = { prompt: ["existing-prompt"] };
403-
const modifiedOptions = { ...baseOptions };
406+
const baseConfig = {
407+
prompts: [{ name: "Existing", prompt: "existing-prompt" }],
408+
};
409+
const baseOptions = {};
404410

405-
// Simulate what ConfigEnhancer does internally
406-
const workflowState = workflowService.getState();
407-
if (workflowState.workflowFile?.prompt) {
408-
modifiedOptions.prompt = [
409-
workflowState.workflowFile.prompt,
410-
...(modifiedOptions.prompt || []),
411-
];
412-
}
413-
414-
expect(modifiedOptions.prompt).toEqual([
415-
"You are a workflow assistant.",
416-
"existing-prompt",
417-
]);
411+
const enhancedConfig = await configEnhancer.enhanceConfig(
412+
baseConfig as any,
413+
baseOptions,
414+
workflowService.getState(),
415+
);
416+
417+
// Workflow prompt should be prepended to existing prompts
418+
expect(enhancedConfig.prompts).toHaveLength(2);
419+
expect(enhancedConfig.prompts?.[0]).toMatchObject({
420+
name: expect.stringContaining("Test Workflow"),
421+
prompt: "You are a workflow assistant.",
422+
});
423+
expect(enhancedConfig.prompts?.[1]).toMatchObject({
424+
name: "Existing",
425+
prompt: "existing-prompt",
426+
});
418427
});
419428

420429
it("should not add workflow prompt when workflow has no prompt", async () => {
@@ -426,43 +435,53 @@ describe("Workflow Integration Tests", () => {
426435
mockLoadPackageFromHub.mockResolvedValue(workflowWithoutPrompt);
427436
await workflowService.initialize("owner/workflow");
428437

429-
const baseOptions = { prompt: ["existing-prompt"] };
430-
const modifiedOptions = { ...baseOptions };
438+
const baseConfig = {
439+
prompts: [{ name: "Existing", prompt: "existing-prompt" }],
440+
};
441+
const baseOptions = {};
442+
443+
const enhancedConfig = await configEnhancer.enhanceConfig(
444+
baseConfig as any,
445+
baseOptions,
446+
workflowService.getState(),
447+
);
431448

432-
// Simulate what ConfigEnhancer does internally
433-
const workflowState = workflowService.getState();
434-
if (workflowState.workflowFile?.prompt) {
435-
modifiedOptions.prompt = [
436-
workflowState.workflowFile.prompt,
437-
...(modifiedOptions.prompt || []),
438-
];
439-
}
440-
441-
expect(modifiedOptions.prompt).toEqual(["existing-prompt"]);
449+
// Should only have the existing prompt, no workflow prompt added
450+
expect(enhancedConfig.prompts).toHaveLength(1);
451+
expect(enhancedConfig.prompts?.[0]).toMatchObject({
452+
name: "Existing",
453+
prompt: "existing-prompt",
454+
});
442455
});
443456
});
444457

445458
describe("ConfigEnhancer prompt integration", () => {
446-
it("should modify options to include workflow prompt as prefix", async () => {
459+
it("should add workflow prompt to config.prompts when workflow is active", async () => {
447460
// Setup workflow service with active workflow
448461
mockLoadPackageFromHub.mockResolvedValue(mockWorkflowFile);
449462
await workflowService.initialize("owner/workflow");
450463

451464
const baseOptions = { prompt: ["user-prompt"] };
465+
const baseConfig = { prompts: [] };
452466

453-
// This should internally modify the options to include workflow prompt
454-
await configEnhancer.enhanceConfig(
455-
{} as any,
467+
// Enhance config with workflow state
468+
const enhancedConfig = await configEnhancer.enhanceConfig(
469+
baseConfig as any,
456470
baseOptions,
457471
workflowService.getState(),
458472
);
459473

460-
// Verify that the workflow prompt was added to the options
461-
// by checking that injectPrompts was called with workflow prompt prefixed
462-
expect(baseOptions.prompt).toHaveLength(1);
474+
// Verify that the workflow prompt was added to config.prompts
475+
expect(enhancedConfig.prompts).toBeDefined();
476+
expect(enhancedConfig.prompts).toHaveLength(1);
477+
expect(enhancedConfig.prompts?.[0]).toMatchObject({
478+
name: expect.stringContaining("Test Workflow"),
479+
prompt: "You are a workflow assistant.",
480+
description: "A test workflow for integration testing",
481+
});
463482
});
464483

465-
it("should work end-to-end with workflow prompt processing", async () => {
484+
it("should work end-to-end with workflow prompt in config", async () => {
466485
// Setup workflow service with active workflow
467486
mockLoadPackageFromHub.mockResolvedValue(mockWorkflowFile);
468487
await workflowService.initialize("owner/workflow");
@@ -472,24 +491,23 @@ describe("Workflow Integration Tests", () => {
472491
"You are a workflow assistant.",
473492
);
474493

475-
// Verify that if we manually apply the same logic as ConfigEnhancer,
476-
// the workflow prompt gets added as prefix
477-
const options = { prompt: ["Tell me about TypeScript"] };
494+
const baseConfig = { prompts: [] };
495+
const baseOptions = { prompt: ["Tell me about TypeScript"] };
478496

479-
if (workflowState.workflowFile?.prompt) {
480-
options.prompt = [workflowState.workflowFile.prompt, ...options.prompt];
481-
}
497+
// Enhance config with workflow
498+
const enhancedConfig = await configEnhancer.enhanceConfig(
499+
baseConfig as any,
500+
baseOptions,
501+
workflowState,
502+
);
482503

483-
expect(options.prompt).toEqual([
504+
// Verify workflow prompt is added to config.prompts
505+
expect(enhancedConfig.prompts).toBeDefined();
506+
expect(enhancedConfig.prompts?.length).toBeGreaterThan(0);
507+
expect(enhancedConfig.prompts?.[0]?.prompt).toBe(
484508
"You are a workflow assistant.",
485-
"Tell me about TypeScript",
486-
]);
487-
488-
// This mimics what processAndCombinePrompts would do
489-
const combinedPrompt = options.prompt.join("\n\n");
490-
expect(combinedPrompt).toBe(
491-
"You are a workflow assistant.\n\nTell me about TypeScript",
492509
);
510+
expect(enhancedConfig.prompts?.[0]?.name).toContain("Test Workflow");
493511
});
494512
});
495513

extensions/cli/src/stream/streamChatResponse.getAllTools.test.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,7 @@ import { getAllTools } from "./streamChatResponse.js";
1313
describe("getAllTools - Tool Filtering", () => {
1414
beforeEach(() => {
1515
// Clean up service container state before each test
16-
const services = [
17-
SERVICE_NAMES.TOOL_PERMISSIONS,
18-
SERVICE_NAMES.AUTH,
19-
SERVICE_NAMES.API_CLIENT,
20-
SERVICE_NAMES.CONFIG,
21-
SERVICE_NAMES.MODEL,
22-
SERVICE_NAMES.MCP,
23-
];
24-
25-
services.forEach((service) => {
16+
Object.values(SERVICE_NAMES).forEach((service) => {
2617
// Reset service state
2718
(serviceContainer as any).services.delete(service);
2819
(serviceContainer as any).factories.delete(service);

extensions/cli/src/stream/streamChatResponse.modeSwitch.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ describe("streamChatResponse - Mode Switch During Streaming", () => {
1313
const toolPermissionService = services.toolPermissions;
1414
beforeEach(async () => {
1515
// Clean up service container state before each test
16-
const services = Object.keys(SERVICE_NAMES);
17-
18-
services.forEach((service) => {
16+
Object.values(SERVICE_NAMES).forEach((service) => {
1917
(serviceContainer as any).services.delete(service);
2018
(serviceContainer as any).factories.delete(service);
2119
(serviceContainer as any).dependencies.delete(service);

extensions/cli/src/stream/streamChatResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export async function streamChatResponse(
391391
// Get response from LLM
392392
const { content, toolCalls, shouldContinue } =
393393
await processStreamingResponse({
394+
isHeadless,
394395
chatHistory,
395396
model,
396397
llmApi,

packages/config-yaml/src/markdown/workflowFiles.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,4 +485,120 @@ describe("parseWorkflowTools", () => {
485485
allBuiltIn: false,
486486
});
487487
});
488+
489+
describe("whitespace validation in colon-separated MCP tool references", () => {
490+
it("should reject MCP tool reference with space after colon", () => {
491+
expect(() => parseWorkflowTools("owner/slug: tool")).toThrow(
492+
'Invalid MCP tool reference "owner/slug: tool": colon-separated tool references cannot contain whitespace',
493+
);
494+
});
495+
496+
it("should reject MCP tool reference with space before colon", () => {
497+
expect(() => parseWorkflowTools("owner/slug :tool")).toThrow(
498+
'Invalid MCP tool reference "owner/slug :tool": colon-separated tool references cannot contain whitespace',
499+
);
500+
});
501+
502+
it("should reject MCP tool reference with spaces around colon", () => {
503+
expect(() => parseWorkflowTools("owner/slug : tool")).toThrow(
504+
'Invalid MCP tool reference "owner/slug : tool": colon-separated tool references cannot contain whitespace',
505+
);
506+
});
507+
508+
it("should reject MCP tool reference with space in tool name", () => {
509+
expect(() => parseWorkflowTools("owner/slug:my tool")).toThrow(
510+
'Invalid MCP tool reference "owner/slug:my tool": colon-separated tool references cannot contain whitespace',
511+
);
512+
});
513+
514+
it("should reject MCP tool reference with space in server slug", () => {
515+
expect(() => parseWorkflowTools("owner /slug:tool")).toThrow(
516+
'Invalid MCP tool reference "owner /slug:tool": colon-separated tool references cannot contain whitespace',
517+
);
518+
});
519+
520+
it("should reject MCP tool reference with tab character", () => {
521+
expect(() => parseWorkflowTools("owner/slug:\ttool")).toThrow(
522+
'Invalid MCP tool reference "owner/slug:\ttool": colon-separated tool references cannot contain whitespace',
523+
);
524+
});
525+
526+
it("should reject MCP tool reference with newline character", () => {
527+
expect(() => parseWorkflowTools("owner/slug:\ntool")).toThrow(
528+
'Invalid MCP tool reference "owner/slug:\ntool": colon-separated tool references cannot contain whitespace',
529+
);
530+
});
531+
532+
it("should reject MCP tool reference with multiple spaces", () => {
533+
expect(() => parseWorkflowTools("owner/slug: tool ")).toThrow(
534+
'Invalid MCP tool reference "owner/slug: tool": colon-separated tool references cannot contain whitespace',
535+
);
536+
});
537+
538+
it("should reject MCP tool reference with leading spaces in tool name", () => {
539+
expect(() => parseWorkflowTools("owner/slug: tool")).toThrow(
540+
'Invalid MCP tool reference "owner/slug: tool": colon-separated tool references cannot contain whitespace',
541+
);
542+
});
543+
544+
it("should accept valid colon-separated MCP tool reference without whitespace", () => {
545+
const result = parseWorkflowTools("owner/package:tool_name");
546+
expect(result).toEqual({
547+
tools: [{ mcpServer: "owner/package", toolName: "tool_name" }],
548+
mcpServers: ["owner/package"],
549+
allBuiltIn: false,
550+
});
551+
});
552+
553+
it("should accept valid MCP tool references with hyphens and underscores", () => {
554+
const result = parseWorkflowTools(
555+
"owner-name/package_name:tool-name_123",
556+
);
557+
expect(result).toEqual({
558+
tools: [
559+
{
560+
mcpServer: "owner-name/package_name",
561+
toolName: "tool-name_123",
562+
},
563+
],
564+
mcpServers: ["owner-name/package_name"],
565+
allBuiltIn: false,
566+
});
567+
});
568+
569+
it("should allow whitespace between comma-separated items", () => {
570+
const result = parseWorkflowTools(
571+
"owner/package:tool1, owner/package:tool2 , bash",
572+
);
573+
expect(result).toEqual({
574+
tools: [
575+
{ mcpServer: "owner/package", toolName: "tool1" },
576+
{ mcpServer: "owner/package", toolName: "tool2" },
577+
{ toolName: "bash" },
578+
],
579+
mcpServers: ["owner/package"],
580+
allBuiltIn: false,
581+
});
582+
});
583+
584+
it("should reject whitespace in one reference but accept valid references in same string", () => {
585+
expect(() =>
586+
parseWorkflowTools("owner/package:tool1, owner/package: tool2, bash"),
587+
).toThrow(
588+
'Invalid MCP tool reference "owner/package: tool2": colon-separated tool references cannot contain whitespace',
589+
);
590+
});
591+
592+
it("should not reject whitespace in MCP server references without colon", () => {
593+
// Note: This behavior may or may not be desired, but documents current behavior
594+
// Server-only references (no colon) don't trigger the whitespace check
595+
expect(() => parseWorkflowTools("owner /package")).not.toThrow();
596+
});
597+
598+
it("should not reject whitespace in built-in tool names", () => {
599+
// Note: This behavior may or may not be desired, but documents current behavior
600+
// Built-in tools (no slash) don't trigger the whitespace check
601+
expect(() => parseWorkflowTools("my tool")).not.toThrow();
602+
});
603+
});
488604
});

packages/config-yaml/src/markdown/workflowFiles.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export function parseWorkflowTools(toolsString?: string): ParsedWorkflowTools {
123123

124124
if (colonIndex > 0) {
125125
// Specific tool: "owner/package:tool_name"
126+
// Reject references with whitespace to prevent silent misconfigurations
127+
if (/\s/.test(toolRef)) {
128+
throw new Error(
129+
`Invalid MCP tool reference "${toolRef}": colon-separated tool references cannot contain whitespace. ` +
130+
`Use format "owner/slug:tool_name" without spaces.`,
131+
);
132+
}
133+
126134
const mcpServer = toolRef.substring(0, colonIndex);
127135
const toolName = toolRef.substring(colonIndex + 1);
128136

0 commit comments

Comments
 (0)