From 285f8202c7d231af9fb113d3a4b19191818bd4d7 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:16:48 +0800 Subject: [PATCH] Update workspace agent file (#889) --- electron/api/routes/agents.ts | 6 ++ electron/utils/openclaw-workspace.ts | 76 ++++++++++++++- tests/unit/strip-first-run.test.ts | 139 +++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/unit/strip-first-run.test.ts diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts index be2b9df..e98c3b3 100644 --- a/electron/api/routes/agents.ts +++ b/electron/api/routes/agents.ts @@ -14,6 +14,7 @@ import { deleteChannelAccountConfig } from '../../utils/channel-config'; import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; +import { ensureClawXContext } from '../../utils/openclaw-workspace'; function scheduleGatewayReload(ctx: HostApiContext, reason: string): void { if (ctx.gatewayManager.getStatus().state !== 'stopped') { @@ -128,6 +129,11 @@ export async function handleAgentRoutes( console.warn('[agents] Failed to sync provider auth after agent creation:', err); }); scheduleGatewayReload(ctx, 'create-agent'); + // Ensure newly provisioned workspaces get ClawX context merge/cleanup + // even when gateway status events do not fire (e.g. in-process reload). + void ensureClawXContext().catch((err) => { + console.warn('[agents] Failed to ensure ClawX context after agent creation:', err); + }); sendJson(res, 200, { success: true, ...snapshot }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/utils/openclaw-workspace.ts b/electron/utils/openclaw-workspace.ts index 2dc5725..3f086a4 100644 --- a/electron/utils/openclaw-workspace.ts +++ b/electron/utils/openclaw-workspace.ts @@ -43,6 +43,66 @@ export function mergeClawXSection(existing: string, section: string): string { return existing.trimEnd() + '\n\n' + wrapped + '\n'; } +/** + * Strip the "## First Run" section from workspace AGENTS.md content. + * This section is seeded by the OpenClaw Gateway but is unnecessary + * for ClawX-managed workspaces. Removes everything from the heading + * line until the next markdown heading (any level) or end of content. + */ +export function stripFirstRunSection(content: string): string { + const lines = content.split('\n'); + const result: string[] = []; + let skipping = false; + let consumedFirstParagraph = false; + let seenBlankAfterParagraph = false; + + for (const line of lines) { + const isHeading = /^#{1,6}\s/.test(line); + const trimmed = line.trim(); + + if (line.trim() === '## First Run') { + skipping = true; + consumedFirstParagraph = false; + seenBlankAfterParagraph = false; + continue; + } + + if (skipping) { + // A new heading marks the end of the First Run block. + if (isHeading) { + skipping = false; + } else if (!consumedFirstParagraph) { + // Drop leading blank lines and the first guidance paragraph. + if (trimmed.length === 0) { + continue; + } + consumedFirstParagraph = true; + continue; + } else if (!seenBlankAfterParagraph) { + // Keep consuming the same paragraph until a blank line appears. + if (trimmed.length === 0) { + seenBlankAfterParagraph = true; + continue; + } + continue; + } else { + // After paragraph + blank line, preserve subsequent body content. + if (trimmed.length === 0) { + continue; + } + skipping = false; + } + } + + if (!skipping) { + result.push(line); + } + } + + // Collapse any resulting triple+ blank lines into double + return result.join('\n').replace(/\n{3,}/g, '\n\n'); +} + // ── Workspace directory resolution ─────────────────────────────── /** @@ -173,10 +233,22 @@ async function mergeClawXContextOnce(): Promise { } const section = await readFile(join(contextDir, file), 'utf-8'); - const existing = await readFile(targetPath, 'utf-8'); + const originalExisting = await readFile(targetPath, 'utf-8'); + let existing = originalExisting; + + // Strip unwanted Gateway-seeded sections before merging + if (targetName === 'AGENTS.md') { + const stripped = stripFirstRunSection(existing); + if (stripped !== existing) { + existing = stripped; + logger.info(`Stripped First Run section from ${targetName} (${workspaceDir})`); + } + } const merged = mergeClawXSection(existing, section); - if (merged !== existing) { + // Compare against on-disk content so we persist changes even when only + // First Run stripping happened and the ClawX section stayed identical. + if (merged !== originalExisting) { await writeFile(targetPath, merged, 'utf-8'); logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`); } diff --git a/tests/unit/strip-first-run.test.ts b/tests/unit/strip-first-run.test.ts new file mode 100644 index 0000000..b85660e --- /dev/null +++ b/tests/unit/strip-first-run.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { mergeClawXSection, stripFirstRunSection } from '../../electron/utils/openclaw-workspace'; + +describe('stripFirstRunSection', () => { + it('removes the First Run section when it exists', () => { + const input = [ + '# AGENTS.md', + '', + 'Some preamble content.', + '', + '## First Run', + '', + "If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.", + '', + '## Other Section', + '', + 'Other content.', + ].join('\n'); + + const result = stripFirstRunSection(input); + expect(result).not.toContain('## First Run'); + expect(result).not.toContain('BOOTSTRAP.md'); + expect(result).toContain('# AGENTS.md'); + expect(result).toContain('Some preamble content.'); + expect(result).toContain('## Other Section'); + expect(result).toContain('Other content.'); + }); + + it('returns content unchanged when no First Run section exists', () => { + const input = '# AGENTS.md\n\nSome content.\n'; + expect(stripFirstRunSection(input)).toBe(input); + }); + + it('handles First Run section at end of file', () => { + const input = [ + '# AGENTS.md', + '', + '## First Run', + '', + 'Bootstrap text.', + '', + ].join('\n'); + + const result = stripFirstRunSection(input); + expect(result).not.toContain('## First Run'); + expect(result).not.toContain('Bootstrap text'); + expect(result).toContain('# AGENTS.md'); + }); + + it('does not collapse adjacent sections', () => { + const input = [ + '## Section A', + 'content a', + '', + '## First Run', + '', + 'bootstrap text', + '', + '## Section B', + 'content b', + ].join('\n'); + + const result = stripFirstRunSection(input); + expect(result).toContain('## Section A'); + expect(result).toContain('content a'); + expect(result).toContain('## Section B'); + expect(result).toContain('content b'); + expect(result).not.toContain('## First Run'); + }); + + it('does not remove sections with similar but different names', () => { + const input = [ + '## First Run Setup', + 'This should stay.', + '', + '## First Run', + 'This should go.', + ].join('\n'); + + const result = stripFirstRunSection(input); + expect(result).toContain('## First Run Setup'); + expect(result).toContain('This should stay.'); + expect(result).not.toContain('This should go.'); + }); + + it('collapses triple blank lines left by removal', () => { + const input = [ + 'before', + '', + '', + '## First Run', + '', + 'text', + '', + '', + 'after', + ].join('\n'); + + const result = stripFirstRunSection(input); + expect(result).not.toMatch(/\n{3,}/); + expect(result).toContain('before'); + expect(result).toContain('after'); + }); + + it('still changes AGENTS content when only First Run is removed', () => { + const section = [ + '## ClawX Environment', + '', + 'You are ClawX.', + ].join('\n'); + const original = [ + '# AGENTS.md', + '', + '## First Run', + '', + "If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.", + '', + '## Session Startup', + '', + 'Read SOUL.md first.', + '', + '', + '## ClawX Environment', + '', + 'You are ClawX.', + '', + '', + ].join('\n'); + + const stripped = stripFirstRunSection(original); + const merged = mergeClawXSection(stripped, section); + + expect(merged).not.toBe(original); + expect(merged).not.toContain('## First Run'); + expect(merged).toContain('## Session Startup'); + expect(merged).toContain(''); + expect(merged).toContain(''); + }); +});