From be258e23ecf4e2d08813807c048c19d7b890539d Mon Sep 17 00:00:00 2001 From: BingZ Date: Sun, 12 Jul 2026 12:43:20 +0800 Subject: [PATCH] Add Grok orchestration group routing (#8058) * docs: design Grok orchestration group * docs: plan Grok orchestration group implementation * fix: add Grok orchestration group * test(orchestration): accept Windows skill newlines * Fix @grok orchestration group matching and remove stale planning docs - Reuse the shared buildAgentNameRe matcher in groups.ts instead of a divergent local regex, so orchestration groups honor the same Windows launcher-suffix rule (grok.exe/.cmd/.bat/.ps1) as the rest of Orca's agent-title detection. - Add test coverage for real Grok OSC title shapes (spinner-collapsed, session titles) and Windows launcher-suffix titles. - Delete the now-completed design and implementation-plan docs for the Grok orchestration group work. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../orchestration-skill-guidance.test.mjs | 11 ++++- skills/orchestration/SKILL.md | 2 +- .../session-scanner-kimi-index-cache.ts | 5 +- .../git/status-submodule-path-cache.test.ts | 6 +-- src/main/ipc/bounded-warning-dedupe.test.ts | 9 +--- src/main/ipc/repos-remote.test.ts | 5 +- src/main/runtime/orchestration/groups.test.ts | 49 +++++++++++++++++++ src/main/runtime/orchestration/groups.ts | 14 +++--- .../project-picker-browse-cache.test.ts | 6 +-- .../src/components/pet/usePetUrl.test.tsx | 3 +- ...linear-agent-skill-setup-reminder-toast.ts | 19 +++---- ...linear-agent-skill-setup-reminders.test.ts | 6 +-- .../src/lib/open-mobile-emulator-tab.test.ts | 16 +++--- ...ce-control-huge-repo-warning-dismissals.ts | 6 +-- src/shared/agent-hook-listener.test.ts | 5 +- src/shared/agent-name-token-match.ts | 2 +- .../harness-injected-user-turns.test.ts | 8 ++- 17 files changed, 109 insertions(+), 63 deletions(-) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 7c7196d86..82b408a33 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -11,7 +11,9 @@ function readSkill() { function getSection(markdown, heading) { const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`)) + const match = markdown.match( + new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`) + ) expect(match).not.toBeNull() @@ -189,6 +191,13 @@ describe('orchestration skill guidance', () => { expect(skill).not.toContain('every 2 minutes') }) + it('documents @grok in the Messaging group address list', () => { + const skill = readSkill() + const messaging = getSection(skill, 'Messaging') + + expect(messaging).toContain('`@grok`') + }) + it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => { const skill = readSkill() const messaging = getSection(skill, 'Messaging') diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index dd50b7756..b7a9bdbac 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -95,7 +95,7 @@ Rules: - Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet. - Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. - `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. -- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:`. +- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:`. - Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`. - Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups. - `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals. diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts index 13ebe3c14..fb5f6926e 100644 --- a/src/main/ai-vault/session-scanner-kimi-index-cache.ts +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -120,10 +120,7 @@ export class KimiSessionIndexCache { } } -function identitiesMatch( - left: KimiSessionIndexIdentity, - right: KimiSessionIndexIdentity -): boolean { +function identitiesMatch(left: KimiSessionIndexIdentity, right: KimiSessionIndexIdentity): boolean { return ( left.changeTimeMs === right.changeTimeMs && left.mtimeMs === right.mtimeMs && diff --git a/src/main/git/status-submodule-path-cache.test.ts b/src/main/git/status-submodule-path-cache.test.ts index 18b4ba693..60adccf14 100644 --- a/src/main/git/status-submodule-path-cache.test.ts +++ b/src/main/git/status-submodule-path-cache.test.ts @@ -114,7 +114,7 @@ describe('submodule path cache', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('does not reuse another branch\'s submodule paths after local or WSL checkout', async () => { + it("does not reuse another branch's submodule paths after local or WSL checkout", async () => { let modulePath = 'main-lib' gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'checkout') { @@ -219,9 +219,7 @@ describe('submodule path cache', () => { ...runtime, checkoutExistingBranch: true }) - await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([ - 'recreated-lib' - ]) + await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual(['recreated-lib']) const configReads = gitExecFileAsyncMock.mock.calls.filter( ([args]) => args[0] === 'config' && args.includes('.gitmodules') diff --git a/src/main/ipc/bounded-warning-dedupe.test.ts b/src/main/ipc/bounded-warning-dedupe.test.ts index 787d8a965..aa8cd623a 100644 --- a/src/main/ipc/bounded-warning-dedupe.test.ts +++ b/src/main/ipc/bounded-warning-dedupe.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - DEFAULT_WARNING_DEDUPE_MAX_KEYS, - shouldEmitBoundedWarning -} from './bounded-warning-dedupe' +import { DEFAULT_WARNING_DEDUPE_MAX_KEYS, shouldEmitBoundedWarning } from './bounded-warning-dedupe' describe('shouldEmitBoundedWarning', () => { it('keeps retained warning keys quiet without cascade eviction after saturation', () => { @@ -29,8 +26,6 @@ describe('shouldEmitBoundedWarning', () => { ) expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual(keys) - expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([ - keys.at(-1) - ]) + expect(keys.filter((key) => shouldEmitBoundedWarning(warningKeys, key))).toEqual([keys.at(-1)]) }) }) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 8b5cb94e5..18f73ca6f 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -155,10 +155,7 @@ vi.mock('./ssh', () => ({ })) import { registerRepoHandlers } from './repos' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' beforeEach(() => { clearGitCapabilityStateForTests() diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 483c8a1fe..5f7f07ca0 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -30,6 +30,7 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@idle')).toBe(true) expect(isGroupAddress('@claude')).toBe(true) expect(isGroupAddress('@droid')).toBe(true) + expect(isGroupAddress('@grok')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -169,6 +170,54 @@ describe('resolveGroupAddress', () => { const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) expect(result).toEqual(['term_b']) }) + + it('matches @grok as a standalone title token and excludes sender', () => { + const terminals = [ + makeSummary('term_a', { title: 'Grok' }), + makeSummary('term_b', { title: 'GROK CLI' }), + makeSummary('term_c', { title: '⠋ Grok' }), + makeSummary('term_d', { title: 'ngrok' }), + makeSummary('term_e', { title: '/tmp/grok' }), + makeSummary('term_f', { title: 'my-grok-worker' }), + makeSummary('term_g', { title: 'Codex CLI' }) + ] + + const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus) + + expect(result).toEqual(['term_b', 'term_c']) + }) + + // Why: the resolver sees the raw OSC title, and Grok CLI's real working/session + // titles carry a trailing " - grok" identity or a spinner-collapsed "⠋ grok" + // (see terminal-title-agent-type.ts). Prove those production shapes resolve. + it('matches real Grok OSC working and session titles', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rotating', { title: '⠋ - fix the flaky suite - grok' }), + makeSummary('term_collapsed', { title: '⠋ grok' }), + makeSummary('term_session', { title: 'Fix the auth bug - grok' }) + ] + + const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session']) + }) + + // Why: Windows agent titles can surface the launcher process name (`grok.exe`); + // the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects + // arbitrary dotted fragments like `grok.py`. + it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_exe', { title: 'grok.exe' }), + makeSummary('term_cmd', { title: 'grok.cmd running' }), + makeSummary('term_dotted', { title: 'grok.py' }) + ] + + const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_exe', 'term_cmd']) + }) }) describe('unknown groups', () => { diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index c897ae038..39ba290bd 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,3 +1,4 @@ +import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' // Why: group addresses enable broadcast messaging to logical groups of agents. @@ -11,7 +12,8 @@ const AGENT_NAME_GROUPS = [ 'opencode', 'mimo', 'gemini', - 'droid' + 'droid', + 'grok' ] as const export type GroupAddress = @@ -24,13 +26,11 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { - const tokenRe = new RegExp(`(? { 0 ) for (let wave = 0; wave < 4; wave += 1) { - expect( - getProjectPickerBrowseCacheEntry('runtime:retained', inserted) - ).toMatchObject({ projects: [expect.objectContaining({ owner: 'retained' })] }) + expect(getProjectPickerBrowseCacheEntry('runtime:retained', inserted)).toMatchObject({ + projects: [expect.objectContaining({ owner: 'retained' })] + }) for (let index = 1; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) { const scope = `scope-${inserted}` rememberProjectPickerBrowseCacheEntry( diff --git a/src/renderer/src/components/pet/usePetUrl.test.tsx b/src/renderer/src/components/pet/usePetUrl.test.tsx index 5169e76e6..e732229ea 100644 --- a/src/renderer/src/components/pet/usePetUrl.test.tsx +++ b/src/renderer/src/components/pet/usePetUrl.test.tsx @@ -26,8 +26,7 @@ const { appState, cacheMocks } = vi.hoisted(() => ({ })) vi.mock('../../store', () => ({ - useAppStore: (selector: (state: typeof appState.current) => unknown) => - selector(appState.current) + useAppStore: (selector: (state: typeof appState.current) => unknown) => selector(appState.current) })) vi.mock('./pet-blob-cache', () => ({ diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts index 801857cd8..62fe43606 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts @@ -137,15 +137,12 @@ export function useLinearAgentSkillSetupReminderToast({ } }, [localDismissStorageKey, missingSetup]) - useEffect( - () => { - if (surface !== 'modal') { - return - } - return () => { - dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey) - } - }, - [localDismissStorageKey, surface] - ) + useEffect(() => { + if (surface !== 'modal') { + return + } + return () => { + dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey) + } + }, [localDismissStorageKey, surface]) } diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts index 6fce02713..37f87a905 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts @@ -24,9 +24,9 @@ describe('linear agent skill setup reminders', () => { MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS ) expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false) - expect(hasLinearAgentSkillSetupReminderStateForTests(`runtime-${churnedRuntimeCount - 1}`)).toBe( - true - ) + expect( + hasLinearAgentSkillSetupReminderStateForTests(`runtime-${churnedRuntimeCount - 1}`) + ).toBe(true) }) it('retains recently reused keys while trimming', () => { diff --git a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts index 3def4ca88..77865652d 100644 --- a/src/renderer/src/lib/open-mobile-emulator-tab.test.ts +++ b/src/renderer/src/lib/open-mobile-emulator-tab.test.ts @@ -228,9 +228,9 @@ describe('openMobileEmulatorTab', () => { ) expect(ensureSimulatorTab).toHaveBeenCalledTimes(2) - const attachCalls = vi.mocked(callRuntimeRpc).mock.calls.filter(([, method]) => - method === 'emulator.attach' - ) + const attachCalls = vi + .mocked(callRuntimeRpc) + .mock.calls.filter(([, method]) => method === 'emulator.attach') expect(attachCalls).toHaveLength(1) expect(isManualSimulatorLaunchPending('wt-1')).toBe(false) }) @@ -245,10 +245,12 @@ describe('openMobileEmulatorTab', () => { ) vi.mocked(ensureSimulatorTab) .mockImplementationOnce(() => { - mockStoreState.unifiedTabsByWorktree['wt-1'] = [{ - id: 'sim-1', - contentType: 'simulator' - }] + mockStoreState.unifiedTabsByWorktree['wt-1'] = [ + { + id: 'sim-1', + contentType: 'simulator' + } + ] return 'sim-1' }) .mockImplementationOnce(() => { diff --git a/src/renderer/src/lib/source-control-huge-repo-warning-dismissals.ts b/src/renderer/src/lib/source-control-huge-repo-warning-dismissals.ts index 2d671f492..9109d4c3c 100644 --- a/src/renderer/src/lib/source-control-huge-repo-warning-dismissals.ts +++ b/src/renderer/src/lib/source-control-huge-repo-warning-dismissals.ts @@ -54,11 +54,7 @@ export function beginHugeRepoWarningProbe( export function hasDismissedHugeRepoWarning(probe: HugeRepoWarningProbe): boolean { const state = hugeRepoWarningStateByWorktreeId.get(probe.worktreeId) - if ( - !state || - state.lifecycleToken !== probe.lifecycleToken || - !state.dismissed - ) { + if (!state || state.lifecycleToken !== probe.lifecycleToken || !state.dismissed) { return false } refreshHugeRepoWarningState(probe.worktreeId, state) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 39a09f8fa..083783328 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -956,7 +956,10 @@ describe('shared agent-hook-listener', () => { 'grok', { paneKey: PANE_KEY, - payload: { hookEventName: 'user_prompt_submit', prompt: 'fix the bug' } + payload: { + hookEventName: 'user_prompt_submit', + prompt: 'fix the bug' + } }, 'production' ) diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index 157f1c8f6..e2084052d 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -33,7 +33,7 @@ export const AGENT_NAMES = [ // `openclaude.exe`; still reject arbitrary dotted path fragments. const WINDOWS_EXECUTABLE_SUFFIX_RE = String.raw`(?:\.(?:exe|cmd|bat|ps1))` -function buildAgentNameRe(name: string): RegExp { +export function buildAgentNameRe(name: string): RegExp { return new RegExp( `(? { isKnownHarnessInjectedUserTurnText('A message arrived from teammate-b:\nhi') ).toBe(true) expect( - isKnownHarnessInjectedUserTurnText('Another Claude session sent a message:\nhi') + isKnownHarnessInjectedUserTurnText( + 'Another Claude session sent a message:\nhi' + ) ).toBe(true) expect(isKnownHarnessInjectedUserTurnText('No response requested.')).toBe(true) expect(isKnownHarnessInjectedUserTurnText('[Request interrupted by user]')).toBe(true) @@ -78,7 +80,9 @@ describe('isKnownHarnessInjectedUserTurnText', () => { expect( isKnownHarnessInjectedUserTurnText(' — why is this flagged?') ).toBe(false) - expect(isKnownHarnessInjectedUserTurnText(' what is this?')).toBe(false) + expect(isKnownHarnessInjectedUserTurnText(' what is this?')).toBe( + false + ) expect(isKnownHarnessInjectedUserTurnText(' sent me this')).toBe(false) })