Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/electron/src/main/lib/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getSdkConfigDir, g
import { getRuntimeStatus } from './runtime-init'
import { getSettings } from './settings-service'
import { buildSystemPrompt, buildDynamicContext } from './agent-prompt-builder'
import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt } from './agent-session-context-prompt'
import { MAX_CONTEXT_MESSAGES, buildContextPrompt, buildRecoveryPrompt, buildReferencedSessionsPrompt, mergeSessionCleanerSkillMention } from './agent-session-context-prompt'
import { permissionService } from './agent-permission-service'
import type { PermissionResult, CanUseToolOptions } from './agent-permission-service'
import { askUserService } from './agent-ask-user-service'
Expand Down Expand Up @@ -1623,6 +1623,9 @@ export class AgentOrchestrator {
})
}
const piCustomTools = [...piBuiltinTools, ...piMcpTools]
const piSkillMentions = agentRuntime === 'pi'
? mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds)
: mentionedSkills
const queryOptions: ClaudeAgentQueryOptions | PiAgentQueryOptions = agentRuntime === 'pi' ? {
agentRuntime: 'pi',
sessionId,
Expand All @@ -1647,7 +1650,7 @@ export class AgentOrchestrator {
piSessionDir: join(getSdkConfigDir(), 'sessions'),
...(allAdditionalDirectories.length > 0 && { additionalDirectories: allAdditionalDirectories }),
...(workspaceSlug ? { additionalSkillPaths: [getWorkspaceSkillsDir(workspaceSlug)] } : {}),
...(mentionedSkills?.length ? { skillMentions: mentionedSkills } : {}),
...(piSkillMentions?.length ? { skillMentions: piSkillMentions } : {}),
...(isCompactCommand ? { compactRequest: true } : {}),
...(sessionMeta?.codexFastMode && channel.provider === 'openai-codex' ? { codexFastMode: true } : {}),
...(codexOAuthCredentials && {
Expand Down Expand Up @@ -2677,6 +2680,7 @@ export class AgentOrchestrator {
enrichedText = `<mentioned_tools>\n${toolLines.join('\n')}\n</mentioned_tools>\n\n${enrichedText}`
}

const skillMentions = mergeSessionCleanerSkillMention(mentionedSkills, mentionedSessionIds)
const uuid = presetUuid || randomUUID()

// 防重记录
Expand Down Expand Up @@ -2706,7 +2710,9 @@ export class AgentOrchestrator {
}
}

await this.adapter.sendQueuedMessage(sessionId, sdkMessage)
await this.adapter.sendQueuedMessage(sessionId, sdkMessage, {
...(skillMentions?.length ? { skillMentions } : {}),
})
console.log(`[Agent 编排] 追加消息已注入: sessionId=${sessionId}, uuid=${uuid}, interrupt=${!!opts?.interrupt}`)

// 立即持久化到 JSONL — 仅存原始文本,不含 prompt 工程块(与 sendMessage 路径一致)
Expand Down
13 changes: 13 additions & 0 deletions apps/electron/src/main/lib/agent-runtime-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,17 @@ describe('Agent Windows Shell 运行环境', () => {

expect(result).toEqual({ PATH: 'C:\\Proma;C:\\Windows\\System32' })
})

test('Given 打包版 CLI When 构建 Pi Bash 环境 Then 注入 PROMA_CLI 并优先其目录', () => {
const result = buildAgentRuntimeEnv({
bundledCliPath: '/Applications/Proma.app/Contents/Resources/bin/proma',
platform: 'darwin',
processEnv: { PATH: '/usr/bin:/bin' },
})

expect(result.env).toMatchObject({
PROMA_CLI: '/Applications/Proma.app/Contents/Resources/bin/proma',
PATH: '/Applications/Proma.app/Contents/Resources/bin:/usr/bin:/bin',
})
})
})
129 changes: 129 additions & 0 deletions apps/electron/src/main/lib/agent-session-context-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import { join } from 'node:path'

type SessionContextPrompt = typeof import('./agent-session-context-prompt')

let sessionContextPrompt: SessionContextPrompt
let tempHome: string
let resourcesPath: string
const originalHome = process.env.HOME
const originalPromaDev = process.env.PROMA_DEV
const originalResourcesPath = process.resourcesPath

mock.module('electron', () => ({
app: {
isPackaged: true,
getPath: () => join(process.env.HOME ?? tempHome, 'Library', 'Application Support'),
getAppPath: () => join(tempHome, 'app'),
},
BrowserWindow: class {},
clipboard: {},
dialog: {},
nativeImage: { createFromPath: () => ({}) },
nativeTheme: {},
powerMonitor: {},
powerSaveBlocker: {},
screen: {},
shell: {},
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf-8'),
},
}))

mock.module('node:os', () => ({
...os,
homedir: () => tempHome,
}))

function writeSessionIndex(): void {
const configDir = join(tempHome, '.proma')
mkdirSync(configDir, { recursive: true })
writeFileSync(join(configDir, 'agent-sessions.json'), JSON.stringify({
version: 1,
sessions: [
{ id: 'current', title: '当前会话', workspaceId: 'workspace-a', createdAt: 1, updatedAt: 2 },
{ id: 'source', title: '来源 <会话>', workspaceId: 'workspace-a', createdAt: 3, updatedAt: 4 },
{ id: 'large', title: '超大来源会话', workspaceId: 'workspace-a', createdAt: 5, updatedAt: 6 },
{ id: 'other-workspace', title: '不应出现', workspaceId: 'workspace-b', createdAt: 7, updatedAt: 8 },
],
}), 'utf-8')
}

function writeSourceSession(): void {
const sessionsDir = join(tempHome, '.proma', 'agent-sessions')
mkdirSync(sessionsDir, { recursive: true })
const rows = [
{ type: 'user', message: { content: [{ type: 'text', text: '请检查 <untrusted> 的实现' }] }, parent_tool_use_id: null },
{ type: 'assistant', message: { content: [{ type: 'text', text: '已完成检查' }] }, parent_tool_use_id: null },
]
writeFileSync(join(sessionsDir, 'source.jsonl'), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf-8')
}

beforeAll(async () => {
tempHome = mkdtempSync(join(os.tmpdir(), 'proma-session-context-prompt-'))
resourcesPath = join(tempHome, 'resources')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
writeFileSync(join(resourcesPath, 'bin', 'proma'), '', 'utf-8')
process.env.HOME = tempHome
process.env.PROMA_DEV = '0'
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath })
writeSessionIndex()
writeSourceSession()
sessionContextPrompt = await import('./agent-session-context-prompt')
})

afterAll(() => {
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
if (originalPromaDev === undefined) delete process.env.PROMA_DEV
else process.env.PROMA_DEV = originalPromaDev
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: originalResourcesPath })
rmSync(tempHome, { recursive: true, force: true })
})

describe('引用 Agent 会话上下文', () => {
test('Given 同工作区会话被显式引用 When 构建 prompt Then 注入受限地图和明确 CLI 深读命令', () => {
const prompt = sessionContextPrompt.buildReferencedSessionsPrompt(
'current',
['source', 'other-workspace'],
'workspace-a',
'proma-dev',
)

expect(prompt).toContain('<referenced_sessions>')
expect(prompt).toContain('<session_outline totalTurns="2" shownTurns="2">')
expect(prompt).toContain('#0 用户 · 请检查 &lt;untrusted&gt; 的实现')
expect(prompt).toContain('"$PROMA_CLI" session info source')
expect(prompt).toContain('"$PROMA_CLI" session export source --turns A-B')
expect(prompt).toContain('不得只依据标题、元数据或地图回答')
expect(prompt).not.toContain('other-workspace')
})

test('Given 引用会话超过地图阈值 When 构建 prompt Then 不同步扫描正文并要求 CLI 深读', () => {
const sessionsDir = join(tempHome, '.proma', 'agent-sessions')
const largeText = 'x'.repeat(2 * 1024 * 1024 + 1)
writeFileSync(join(sessionsDir, 'large.jsonl'), `${JSON.stringify({
type: 'user',
message: { content: [{ type: 'text', text: largeText }] },
parent_tool_use_id: null,
})}\n`, 'utf-8')

const prompt = sessionContextPrompt.buildReferencedSessionsPrompt('current', ['large'], 'workspace-a')

expect(prompt).toContain('<session_outline skipped="too-large"')
expect(prompt).toContain('请先使用 CLI 的 info 和 outline 命令渐进读取')
expect(prompt).not.toContain(largeText)
})

test('Given 会话引用与 Skill 引用同时存在 When Pi 处理引用 Then 保留用户 Skill 并补充 session-cleaner', () => {
expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], ['source']))
.toEqual(['automation', 'session-cleaner'])
expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['session-cleaner'], ['source']))
.toEqual(['session-cleaner'])
expect(sessionContextPrompt.mergeSessionCleanerSkillMention(['automation'], [])).toEqual(['automation'])
})
})
103 changes: 86 additions & 17 deletions apps/electron/src/main/lib/agent-session-context-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { statSync } from 'node:fs'
import { groupIntoTurns, formatOutlineLine, outline, toTranscript } from '@proma/session-core'
import { readSessionMessages } from '@proma/session-core/node'
import { getAgentSessionMeta, getAgentSessionSDKMessages } from './agent-session-manager'
import { getBundledCliPath, getConfigDirName } from './config-paths'
import { getAgentSessionMessagesPath, getBundledCliPath, getConfigDirName, getDevCliEntryPath } from './config-paths'

/** 最大回填消息条数 */
export const MAX_CONTEXT_MESSAGES = 20

/** 显式引用会话时注入的 turn 地图上限,正文仍由 Agent 按需通过 CLI 读取。 */
const MAX_REFERENCED_SESSION_OUTLINE_TURNS = 16

/** 超过此大小的会话只注入读取指引,避免主进程为引用地图同步扫描超大 JSONL。 */
const MAX_REFERENCED_SESSION_OUTLINE_BYTES = 2 * 1024 * 1024

/** 单条工具摘要最大字符数 */
const MAX_TOOL_SUMMARY_LENGTH = 200

Expand All @@ -16,22 +25,29 @@ function getSessionHistoryPath(sessionId: string): string {
return `~/${getConfigDirName()}/agent-sessions/${sessionId}.jsonl`
}

function canUseSessionCleaner(): boolean {
return !!getBundledCliPath()
}

function getSessionCleanerSkillName(workspaceSlug?: string): string {
return workspaceSlug
? `proma-workspace-${workspaceSlug}:session-cleaner`
: 'session-cleaner'
}

function getSessionCliCommandPrefix(): string {
return getBundledCliPath() ? '"$PROMA_CLI"' : 'proma'
function quoteShellArgument(value: string): string {
return `"${value.replace(/[$`\\"]/g, '\\$&')}"`
}

function getSessionCliCommandPrefix(): string | undefined {
if (getBundledCliPath()) return '"$PROMA_CLI"'

const devCliEntryPath = getDevCliEntryPath()
return devCliEntryPath ? `bun ${quoteShellArgument(devCliEntryPath)} --dev` : undefined
}

function buildSessionCliAccessGuide(sessionId: string, historyPath: string, workspaceSlug?: string): string {
const cli = getSessionCliCommandPrefix()
if (!cli) {
return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}`
}

const skillName = getSessionCleanerSkillName(workspaceSlug)
return [
`优先使用 session-cleaner skill(${skillName})读取当前会话历史;它是 Proma CLI 的薄封装,会把 Agent JSONL 清洗为干净对话。`,
Expand All @@ -46,21 +62,60 @@ function buildSessionCliAccessGuide(sessionId: string, historyPath: string, work
}

function buildCurrentSessionHistoryInstruction(sessionId: string, workspaceSlug?: string): string {
const historyPath = getSessionHistoryPath(sessionId)
if (canUseSessionCleaner()) {
return buildSessionCliAccessGuide(sessionId, historyPath, workspaceSlug)
}
return buildSessionCliAccessGuide(sessionId, getSessionHistoryPath(sessionId), workspaceSlug)
}

function buildReferencedSessionOutline(sessionId: string): string | undefined {
try {
const sessionPath = getAgentSessionMessagesPath(sessionId)
const bytes = statSync(sessionPath).size
if (bytes > MAX_REFERENCED_SESSION_OUTLINE_BYTES) {
return [
`<session_outline skipped="too-large" bytes="${bytes}">`,
'宿主未扫描超大历史;请先使用 CLI 的 info 和 outline 命令渐进读取。',
'</session_outline>',
].join('\n')
}

return `请先读取上述完整历史文件以恢复上下文。会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大;如果完整读取风险较高,请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。History path: ${historyPath}`
const entries = outline(toTranscript(groupIntoTurns(readSessionMessages(sessionPath))))
const visibleEntries = entries.slice(-MAX_REFERENCED_SESSION_OUTLINE_TURNS)
const omittedEntries = entries.length - visibleEntries.length
const lines = visibleEntries.map((entry) => escapeContextText(formatOutlineLine(entry)))

return [
`<session_outline totalTurns="${entries.length}" shownTurns="${visibleEntries.length}">`,
'以下是宿主生成的受限定位地图,不是完整会话正文;预览可能截断,不能据此声称已读取完整历史。',
...(omittedEntries > 0 ? [`已省略最早 ${omittedEntries} 个 turn。`] : []),
...lines,
'</session_outline>',
].join('\n')
} catch (error) {
console.warn(`[Agent 编排] 无法生成引用会话地图: sessionId=${sessionId}`, error)
return undefined
}
}

function buildReferencedSessionsHistoryInstruction(workspaceSlug?: string): string {
if (canUseSessionCleaner()) {
const cli = getSessionCliCommandPrefix()
if (cli) {
const skillName = getSessionCleanerSkillName(workspaceSlug)
return `需要这些会话的上下文时,优先使用 session-cleaner skill(${skillName})或 Proma CLI 读取清洗后的会话历史。按 info → outline/search → export 的顺序渐进式读取;不要假设会话内容,也不要直接 Read 原始 .jsonl 历史文件。`
return [
`这些会话已由宿主提供受限 turn 地图,但不含完整正文。若回答、总结、继续执行或决策依赖被引用会话的内容,必须先用 session-cleaner skill(${skillName})或 Proma CLI 读取相关 turn;不得只依据标题、元数据或地图回答。`,
`可用 CLI 命令前缀: ${cli}`,
'按 info → outline/search → export 的顺序渐进读取;不要直接 Read 原始 .jsonl 历史文件。',
].join('\n')
}

return `不要假设这些会话的内容;需要上下文时,请先读取对应的 History path,再基于读取结果继续完成任务。\n\n重要提示:会话历史文件(.jsonl)可能包含大量消息和 tool results,文件较大。请优先使用 Grep 搜索关键词定位相关消息片段,再局部读取。避免一次性 Read 整个大文件。`
return `这些会话仅提供受限 turn 地图,不含完整正文。若回答依赖被引用会话的内容,必须先读取对应的 History path,再基于读取结果继续完成任务。会话历史文件(.jsonl)可能包含大量消息和 tool results,应优先用 Grep 定位后局部读取,避免一次性 Read 整个文件。`
}

/** Pi 没有原生 Skill 工具;显式会话引用必须显式展开 session-cleaner 的 SOP。 */
export function mergeSessionCleanerSkillMention(
mentionedSkills: string[] | undefined,
mentionedSessionIds: string[] | undefined,
): string[] | undefined {
if (!mentionedSessionIds?.some(Boolean)) return mentionedSkills
return [...new Set([...(mentionedSkills ?? []), 'session-cleaner'])]
}

/**
Expand Down Expand Up @@ -172,14 +227,17 @@ export function buildRecoveryPrompt(
return `${recoveryBlock}\n\n${currentUserMessage}`
}

function escapeContextAttr(value: string): string {
function escapeContextText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}

function escapeContextAttr(value: string): string {
return escapeContextText(value).replace(/"/g, '&quot;')
}

export function buildReferencedSessionsPrompt(
currentSessionId: string,
mentionedSessionIds?: string[],
Expand All @@ -201,10 +259,21 @@ export function buildReferencedSessionsPrompt(

const title = escapeContextAttr(meta.title)
const historyPath = getSessionHistoryPath(referencedSessionId)
const cli = getSessionCliCommandPrefix()
const commands = cli
? [
`读取此会话:${cli} session info ${referencedSessionId}`,
`${cli} session outline ${referencedSessionId}`,
`根据地图选择 ${cli} session export ${referencedSessionId} --turns A-B 或 --tail N。`,
].join('\n')
: ''
const sessionOutline = buildReferencedSessionOutline(referencedSessionId)
sessionBlocks.push(
`<session id="${referencedSessionId}" title="${title}" updatedAt="${meta.updatedAt}">\n` +
`CLI target: ${referencedSessionId}\n` +
`History path: ${historyPath}\n` +
`${commands}\n` +
`${sessionOutline ?? ''}\n` +
'</session>',
)
}
Expand Down
17 changes: 17 additions & 0 deletions apps/electron/src/main/lib/config-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,23 @@ export function getBundledCliPath(): string | undefined {
return existsSync(cliPath) ? cliPath : undefined
}

/**
* 获取开发模式下 CLI 的 TypeScript 入口。
*
* 打包版使用自包含二进制;开发模式则由 Bun 直接运行源码,并显式加 --dev
* 以读取 ~/.proma-dev,避免误读正式版会话库。
*/
export function getDevCliEntryPath(): string | undefined {
try {
const { app } = require('electron')
if (app.isPackaged || typeof app.getAppPath !== 'function') return undefined
const entryPath = join(app.getAppPath(), '..', 'cli', 'src', 'index.ts')
return existsSync(entryPath) ? entryPath : undefined
} catch {
return undefined
}
}

/**
* 从 SKILL.md 的 YAML frontmatter 中解析 version 字段
*
Expand Down