From 94c4a2d2233e9e54d0febddda42f9bc08413d40f Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 15 Sep 2026 11:09:32 +0800 Subject: [PATCH] fix(hub,cli,web): give web-created team sessions their team context at spawn Root cause of 'lead never answers': sessions created through the web form never received HAPI_TEAM_* env, so their system prompts had no team rules (only spawn_peer-created members did). Now: - /api/machines/:id/spawn accepts a team descriptor; the runner exports the env as before - team mode creates the team first, spawns the lead with the team context, then attaches it as lead; member mode spawns with the team context before joining - OpenCode system prompts (title + native tool instruction) carry the team block too - human ping copy is action-first: reply directly (auto-synced to the group); do not run shell commands or explore the team --- cli/src/opencode/utils/systemPrompt.ts | 5 ++-- hub/src/teams/teamService.ts | 2 +- hub/src/web/routes/machines.ts | 3 ++- shared/src/apiTypes.ts | 8 +++++- web/src/api/client.ts | 8 +++--- web/src/components/NewSession/index.test.tsx | 18 ++++++++++--- web/src/components/NewSession/index.tsx | 28 +++++++++++++++----- web/src/hooks/mutations/useSpawnSession.ts | 4 ++- 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33..8f583c05 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -15,6 +15,7 @@ import { } from '@/modules/common/displayImagePrompt'; import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; +import { withTeamInstruction } from '@/modules/team/teamPrompt'; /** * Title and display_image / display_video / display_media instructions for OpenCode to call the hapi MCP tools. @@ -30,7 +31,7 @@ export const TITLE_INSTRUCTION = trimIdent(` `); export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withTeamInstruction(withSessionSummaryInstruction(TITLE_INSTRUCTION, env), 'hapi_', env) } /** @@ -50,7 +51,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` `); export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env) + return withTeamInstruction(withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env), 'hapi_', env) } /** diff --git a/hub/src/teams/teamService.ts b/hub/src/teams/teamService.ts index 9b765370..53f62d7b 100644 --- a/hub/src/teams/teamService.ts +++ b/hub/src/teams/teamService.ts @@ -346,7 +346,7 @@ export class TeamService { fromSessionId: input.fromSessionId, fromRole: input.fromRole, text: input.fromKind === 'human' - ? `${input.text}\n\n(这是人类在团队群里的消息,直接回复即可,回复会自动同步到群聊。)` + ? `${input.text}\n\n(人类在团队群里对你说话:直接回复本条消息即可,回复会自动同步到群聊;不要为此执行 shell 命令或查询团队成员。)` : input.text }) try { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index bd6d1c99..41f04ded 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -114,7 +114,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho undefined, parsed.data.collaborationMode, parsed.data.copilotAgentMode, - startingMode + startingMode, + parsed.data.team ) return c.json(result) }) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index c8d4f5f0..96161223 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -823,7 +823,13 @@ export const SpawnSessionRequestSchema = z.object({ serviceTier: z.enum(['fast', 'standard']).optional(), collaborationMode: CodexCollaborationModeSchema.optional(), copilotAgentMode: CopilotAgentModeSchema.optional(), - startingMode: z.enum(['remote', 'pty']).optional() + startingMode: z.enum(['remote', 'pty']).optional(), + /** Agent Team context: runner exports HAPI_TEAM_* into the child CLI. */ + team: z.object({ + id: z.string().min(1), + name: z.string(), + role: z.string().min(1) + }).optional() }) export type SpawnSessionRequest = z.infer diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9c31af8b..58775cb2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -483,7 +483,7 @@ export class ApiClient { async updateTeam( teamId: string, - body: { name?: string; status?: 'active' | 'archived' } + body: { name?: string; status?: 'active' | 'archived'; leadSessionId?: string | null } ): Promise<{ team: TeamSummary }> { return await this.request<{ team: TeamSummary }>( `/api/teams/${encodeURIComponent(teamId)}`, @@ -1117,7 +1117,8 @@ export class ApiClient { serviceTier?: 'fast' | 'standard', collaborationMode?: CodexCollaborationMode, copilotAgentMode?: CopilotAgentMode, - startingMode?: 'remote' | 'pty' + startingMode?: 'remote' | 'pty', + team?: { id: string; name: string; role: string } ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', @@ -1134,7 +1135,8 @@ export class ApiClient { serviceTier, collaborationMode, copilotAgentMode, - startingMode + startingMode, + ...(team ? { team } : {}) }) }) } diff --git a/web/src/components/NewSession/index.test.tsx b/web/src/components/NewSession/index.test.tsx index 9f6655e9..24b77a2e 100644 --- a/web/src/components/NewSession/index.test.tsx +++ b/web/src/components/NewSession/index.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ onTeamSuccess: vi.fn(), onTeamMemberAdded: vi.fn(), addTeamMember: vi.fn(), + updateTeam: vi.fn(), notification: vi.fn(), checkPathsExists: vi.fn(), codexModelsLoading: false, @@ -1179,6 +1180,10 @@ describe('NewSession launch preferences', () => { })) expect(mocks.onTeamMemberAdded).toHaveBeenCalledWith('team-1') expect(mocks.onSuccess).not.toHaveBeenCalled() + // Spawn carries the team context so the member gets HAPI_TEAM_* env. + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + team: { id: 'team-1', name: 'Auth 重构', role: 'Builder A' } + })) // Team type is hidden while adding a member. expect(screen.queryByLabelText('team')).toBeNull() }) @@ -1186,6 +1191,8 @@ describe('NewSession launch preferences', () => { it('creates a team from the team session type', async () => { const createTeam = vi.fn().mockResolvedValue({ team: { id: 'team-1' } }) ;(api as unknown as { createTeam: typeof createTeam }).createTeam = createTeam + ;(api as unknown as { updateTeam: typeof mocks.updateTeam }).updateTeam = mocks.updateTeam + mocks.updateTeam.mockResolvedValue({ team: { id: 'team-1' } }) mocks.spawnSession.mockResolvedValue({ type: 'success', sessionId: 'session-team-lead' }) render( @@ -1208,12 +1215,15 @@ describe('NewSession launch preferences', () => { await waitFor(() => expect(screen.getByTestId('create')).toBeEnabled()) fireEvent.click(screen.getByTestId('create')) - await waitFor(() => expect(createTeam).toHaveBeenCalledWith({ - name: 'Auth 重构', + await waitFor(() => expect(createTeam).toHaveBeenCalledWith({ name: 'Auth 重构' })) + // The lead is a plain session spawned with the team context, then attached. + expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'simple', + team: { id: 'team-1', name: 'Auth 重构', role: 'lead' } + })) + await waitFor(() => expect(mocks.updateTeam).toHaveBeenCalledWith('team-1', { leadSessionId: 'session-team-lead' })) - // The lead itself is a plain session. - expect(mocks.spawnSession).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'simple' })) expect(mocks.onTeamSuccess).toHaveBeenCalledWith('team-1') expect(mocks.onSuccess).not.toHaveBeenCalled() }) diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 6afe18dd..b94f345b 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1923,6 +1923,24 @@ export function NewSession(props: { return } + // Team sessions get their team context at spawn time so the runner + // exports HAPI_TEAM_* and the agent's system prompt carries the team + // rules. Team mode creates the team first (lead is attached after the + // session exists); member mode adopts the session afterwards. + let teamForSpawn: { id: string; name: string; role: string } | undefined + let createdTeamId: string | undefined + if (sessionType === 'team') { + const created = await props.api.createTeam({ name: teamName.trim() }) + createdTeamId = created.team.id + teamForSpawn = { id: createdTeamId, name: teamName.trim(), role: 'lead' } + } else if (props.teamId) { + teamForSpawn = { + id: props.teamId, + name: props.teamName ?? '', + role: memberRole.trim() + } + } + const result = await spawnSession({ machineId, directory: trimmedDirectory, @@ -1941,6 +1959,7 @@ export function NewSession(props: { serviceTier: resolvedServiceTier, collaborationMode: resolvedCollaborationMode, copilotAgentMode: agent === 'copilot' ? copilotAgentMode : undefined, + team: teamForSpawn }) @@ -1950,12 +1969,9 @@ export function NewSession(props: { clearNewSessionFormDraft() setLastUsedMachineId(machineId) addRecentPath(machineId, trimmedDirectory) - if (sessionType === 'team') { - const created = await props.api.createTeam({ - name: teamName.trim(), - leadSessionId: result.sessionId, - }) - props.onTeamSuccess(created.team.id) + if (sessionType === 'team' && createdTeamId) { + await props.api.updateTeam(createdTeamId, { leadSessionId: result.sessionId }) + props.onTeamSuccess(createdTeamId) return } if (props.teamId) { diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index c9e2d591..5fabc7e3 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -19,6 +19,7 @@ type SpawnInput = { collaborationMode?: CodexCollaborationMode copilotAgentMode?: CopilotAgentMode startingMode?: 'remote' | 'pty' + team?: { id: string; name: string; role: string } } export function useSpawnSession(api: ApiClient | null): { @@ -47,7 +48,8 @@ export function useSpawnSession(api: ApiClient | null): { input.serviceTier, input.collaborationMode, input.copilotAgentMode, - input.startingMode + input.startingMode, + input.team ) }, onSuccess: () => {