GitNexus/gitnexus-web/test/unit/agent-prompt.test.ts
Gergő Magyar 6dc6544365
fix(web): chat-only mode for large projects to prevent WebUI hang (#2178) (#2185)
* feat(web): add graph-load skip decision helper and node threshold (#2178)

* feat(web): skip graph download in connectToServer for chat-only mode (#2178)

* feat(web): add graphMode state and empty-graph chat-only handling (#2178)

* feat(web): read and thread ?skipGraph URL param through connect flow (#2178)

* feat(web): chat-only empty state with load-graph-anyway escape hatch (#2178)

* style(web): apply prettier formatting to graph-load files (#2178)

* fix(review): apply autofix feedback

- Fail-safe confirm + authoritative node count (P1: prevent re-triggering the hang via Load-graph-anyway when count unknown)
- In-flight guard on loadGraphAnyway (P1: double-fire)
- Honor explicit ?skipGraph in onAnalyzeComplete and DropZone (R6/U4)
- Extract buildGraphFromConnectResult shared helper (DRY across 3 connect sites)
- Add tests: switchRepo skip path, threshold config override, loadGraphAnyway error path, confirm fail-safe, in-flight guard

* fix(review): address tri-review findings

- P1 (correctness+adversarial+risk): stop the cross-repo / F5 chat-only leak.
  loadGraphAnyway no longer persists ?skipGraph=0, and onAnalyzeComplete +
  DropZone no longer inherit a stale ?skipGraph for a different repo — both
  could bypass auto-detect and re-trigger the #2178 hang. ?skipGraph is now a
  bookmark hint honored only by the initial auto-connect; in-session repo
  changes auto-detect.
- P2 (performance): auto-detect now also skips on edge count (edge-driven
  force-layout cliff), not just nodes; LARGE_GRAPH_EDGE_THRESHOLD default 50K.
- P2 (julik): reset graphMode/chatOnlyNodeCount at the top of switchRepo so a
  failed switch can't leave a stale chat-only overlay.
- P2 (julik): set serverBaseUrl before awaiting handleServerConnect in
  auto-connect so the Load-graph-anyway button isn't briefly a no-op.
- P2 (risk): hide the misleading '0 nodes / 0 edges' stats in chat-only mode
  (Header + StatusBar).
- P2 (performance): guard the GraphCanvas layout effect against the empty
  chat-only graph.
- Tests: edge-threshold decision + connectToServer edge-trigger; load-anyway
  no longer asserts URL persistence.

* fix(web): make Load-graph-anyway cancellable, unmount-safe, fail-safe confirm (#2178)

- AbortController + mountedRef: cancel the in-flight download on unmount and
  guard every post-await setState by the mounted ref (an abort surfaces as a
  BackendError, not a DOMException AbortError, so name-checks would miss it)
- Stale-result guard: a load-anyway that resolves after a concurrent switchRepo
  no longer clobbers the new repo's graph/mode/count
- GraphCanvas confirm fails SAFE (treat as declined) when window.confirm is
  unavailable or throws, instead of silently proceeding into a large download

* fix(web): make the AI agent and chat surface aware of chat-only mode (#2178)

- buildDynamicSystemPrompt + createGraphRAGAgent take a chatOnly flag and append
  a note (both prompt branches) that supersedes VISUAL GROUNDING: the graph isn't
  loaded, [[Type:Name]] node citations won't highlight, prefer [[path:START-END]]
- initializeAgent resolves chatOnly = opts ?? graphModeRef.current==='chatOnly':
  connect-flow callers (handleServerConnect, switchRepo, loadGraphAnyway re-init)
  pass it explicitly; lazy/settings re-inits fall back to live mode via the ref
- loadGraphAnyway re-inits the agent (chatOnly:false) after a full load so the
  prompt drops the note
- RightPanel shows a chat-only banner so the degradation is visible where AI
  output renders (en + zh-CN)

* fix(web): streaming circuit breaker for graphs with missing size stats (#2178)

- GraphTooLargeError + a mid-stream breaker in parseNdjsonGraphResponse: count
  nodes/relationships as they arrive and abort (cancel reader in try/finally,
  then throw) the moment either crosses its limit — reusing the existing node/
  edge thresholds, no new magic constant. Throwing right after the offending
  push means a later error record in the same chunk can't pre-empt it.
- fetchGraph gains optional maxNodes/maxEdges (off by default → existing callers
  unchanged). connectToServer arms them only for auto-detect downloads
  (skipGraph !== false) and catches GraphTooLargeError → chat-only, re-throwing
  every other error. This backstops the no-stats fail-open path that could
  otherwise re-trigger the original hang.

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-13 11:07:58 +01:00

112 lines
4.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { BASE_SYSTEM_PROMPT } from '../../src/core/llm/agent';
import { buildDynamicSystemPrompt, type CodebaseContext } from '../../src/core/llm/context-builder';
import {
createGraphRAGTools,
GRAPH_RAG_TOOL_NAMES,
type GraphRAGBackend,
} from '../../src/core/llm/tools';
import { NODE_REF_REGEX } from '../../src/lib/grounding-patterns';
const MINIMAL_CONTEXT: CodebaseContext = {
stats: {
projectName: 'proj',
fileCount: 0,
functionCount: 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
},
hotspots: [],
folderTree: '',
};
/** Legacy or phantom tool names that must not appear in the system prompt. */
const FORBIDDEN_TOOL_NAMES = [
'hybrid_search',
'semantic_search',
'semantic_search_with_context',
'execute_cypher',
'execute_vector_cypher',
'grep_code',
'read_file',
'get_graph_schema',
'get_code_content',
'get_codebase_stats',
] as const;
/**
* No-op backend. createGraphRAGTools only captures these methods inside each tool's
* async execute closure — it never invokes them at construction time — so empty
* implementations are enough to build the tools and read their registered names.
*/
const stubBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
grep: async () => [],
readFile: async () => '',
};
describe('BASE_SYSTEM_PROMPT tool parity', () => {
it('documents every registered Graph RAG tool by exact name', () => {
for (const name of GRAPH_RAG_TOOL_NAMES) {
expect(BASE_SYSTEM_PROMPT).toContain(`\`${name}\``);
}
});
it('keeps GRAPH_RAG_TOOL_NAMES in sync with the tools createGraphRAGTools registers', () => {
const registered = createGraphRAGTools(stubBackend).map((t) => t.name);
expect(registered.sort()).toEqual([...GRAPH_RAG_TOOL_NAMES].sort());
});
it('does not reference legacy or non-existent tool names', () => {
for (const name of FORBIDDEN_TOOL_NAMES) {
// Word-boundary match catches both backticked and bare-prose mentions.
expect(BASE_SYSTEM_PROMPT).not.toMatch(new RegExp(`\\b${name}\\b`));
}
});
it('uses explicit file citation format expected by the UI parser', () => {
expect(BASE_SYSTEM_PROMPT).toMatch(/\[\[src\/[^\]]+:\d+-\d+\]\]/);
expect(BASE_SYSTEM_PROMPT).not.toContain('[[file:line]]');
});
it('documents a parser-recognized symbol citation format', () => {
// Use the UI parser's own allowlist (NODE_REF_REGEX) so this tracks the parser
// instead of forking its label list. NODE_REF_REGEX is /g; use a non-global copy
// so the match is stateless.
expect(BASE_SYSTEM_PROMPT).toMatch(new RegExp(NODE_REF_REGEX.source));
});
it('documents typed node labels, not polymorphic CodeNode', () => {
expect(BASE_SYSTEM_PROMPT).toContain('MATCH (f:Function)');
expect(BASE_SYSTEM_PROMPT).not.toContain('CodeNode');
expect(BASE_SYSTEM_PROMPT).not.toContain('INHERITS');
});
it('clarifies highlight_in_graph is not a callable tool', () => {
// Reword-proof, registry-level guarantee: the load-bearing fact is that
// highlight_in_graph is not a registered tool, regardless of prompt phrasing.
expect(GRAPH_RAG_TOOL_NAMES).not.toContain('highlight_in_graph');
// The prompt still addresses it explicitly...
expect(BASE_SYSTEM_PROMPT).toContain('highlight_in_graph');
// ...and must never instruct the model to call it (guards an affirmative reword).
expect(BASE_SYSTEM_PROMPT).not.toMatch(/\b(?:use|call|invoke)\s+`?highlight_in_graph/i);
});
});
describe('buildDynamicSystemPrompt chat-only mode (#2178)', () => {
it('appends a chat-only note that overrides VISUAL GROUNDING when chatOnly', () => {
const prompt = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, true);
expect(prompt).toContain('CHAT-ONLY MODE');
expect(prompt).toMatch(/node citations will NOT highlight/i);
expect(prompt).toContain('[[path:START-END]]');
});
it('leaves the prompt unchanged when chatOnly is false/omitted', () => {
const full = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT);
const explicitFalse = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, false);
expect(full).toBe(explicitFalse);
expect(full).not.toContain('CHAT-ONLY MODE');
});
});