diff --git a/apps/server/apps/gateway/src/routines/__tests__/routines-creation-flow.integration.spec.ts b/apps/server/apps/gateway/src/routines/__tests__/routines-creation-flow.integration.spec.ts index 949cc95e..a03afb4c 100644 --- a/apps/server/apps/gateway/src/routines/__tests__/routines-creation-flow.integration.spec.ts +++ b/apps/server/apps/gateway/src/routines/__tests__/routines-creation-flow.integration.spec.ts @@ -612,6 +612,10 @@ describe('Routine Creation Flow — integration', () => { userId: USER_ID, sessionId: SESSION_ID, team9Context: expect.objectContaining({ + source: 'team9', + scopeType: 'dm', + scopeId: CHANNEL_ID, + peerUserId: USER_ID, routineId: ROUTINE_ID, creatorUserId: USER_ID, creationChannelId: CHANNEL_ID, @@ -619,17 +623,10 @@ describe('Routine Creation Flow — integration', () => { language: 'zh-CN', }), componentConfigs: expect.objectContaining({ - 'team9-routine-creation': expect.objectContaining({ + 'team9-routine-creation': { routineId: ROUTINE_ID, isCreationChannel: true, - team9Context: expect.objectContaining({ - routineId: ROUTINE_ID, - creatorUserId: USER_ID, - creationChannelId: CHANNEL_ID, - isCreationChannel: true, - language: 'zh-CN', - }), - }), + }, }), }), TENANT_ID, diff --git a/apps/server/apps/gateway/src/routines/routines.service.spec.ts b/apps/server/apps/gateway/src/routines/routines.service.spec.ts index c35389dd..66b4d69c 100644 --- a/apps/server/apps/gateway/src/routines/routines.service.spec.ts +++ b/apps/server/apps/gateway/src/routines/routines.service.spec.ts @@ -165,11 +165,10 @@ describe('RoutinesService — TaskCast integration', () => { .mockResolvedValue({ sessionId: 'pre-created-session' }), getSession: jest.fn().mockResolvedValue({ sessionId: 'existing-session', - team9Context: { routineId: 'routine-1' }, componentConfigs: { 'team9-routine-creation': { routineId: 'routine-1', - team9Context: { routineId: 'routine-1' }, + isCreationChannel: true, }, }, }), @@ -4254,6 +4253,16 @@ describe('RoutinesService — TaskCast integration', () => { const BOT_USER_ID = 'bot-user-1'; const AGENT_ID = 'agent-1'; const CHANNEL_ID = 'channel-1'; + const REQUIRED_ROUTINE_TEAM9_CONTEXT = { + source: 'team9', + scopeType: 'dm', + scopeId: CHANNEL_ID, + peerUserId: USER_ID, + routineId: ROUTINE_ID, + creatorUserId: USER_ID, + creationChannelId: CHANNEL_ID, + isCreationChannel: true, + }; /** * Mock the sequence of DB calls that startCreationSession performs @@ -4376,17 +4385,12 @@ describe('RoutinesService — TaskCast integration', () => { expect(clawHiveService.createSession).toHaveBeenCalledWith( AGENT_ID, expect.objectContaining({ + team9Context: expect.objectContaining(REQUIRED_ROUTINE_TEAM9_CONTEXT), componentConfigs: expect.objectContaining({ - 'team9-routine-creation': expect.objectContaining({ + 'team9-routine-creation': { routineId: ROUTINE_ID, isCreationChannel: true, - team9Context: expect.objectContaining({ - routineId: ROUTINE_ID, - creatorUserId: USER_ID, - creationChannelId: CHANNEL_ID, - isCreationChannel: true, - }), - }), + }, }), }), TENANT_ID, @@ -4411,7 +4415,7 @@ describe('RoutinesService — TaskCast integration', () => { expect(createSessionOrder).toBeLessThan(sendInputOrder); }); - it('is idempotent when creationChannelId already set AND channel is routine-session', async () => { + it('is idempotent for the persisted context-free routine component config', async () => { // Step 1: getRoutineOrThrow returns a draft with both fields set db.limit.mockResolvedValueOnce([ { @@ -4421,8 +4425,8 @@ describe('RoutinesService — TaskCast integration', () => { botId: BOT_ID, status: 'draft', title: 'Test Draft', - creationChannelId: 'existing-channel', - creationSessionId: 'existing-session', + creationChannelId: CHANNEL_ID, + creationSessionId: `team9/${TENANT_ID}/${AGENT_ID}/dm/${CHANNEL_ID}`, folderId: 'folder-existing', }, ]); @@ -4443,12 +4447,32 @@ describe('RoutinesService — TaskCast integration', () => { expect(clawHiveService.createSession).not.toHaveBeenCalled(); expect(clawHiveService.sendInput).not.toHaveBeenCalled(); expect(result).toEqual({ - creationChannelId: 'existing-channel', - creationSessionId: 'existing-session', + creationChannelId: CHANNEL_ID, + creationSessionId: `team9/${TENANT_ID}/${AGENT_ID}/dm/${CHANNEL_ID}`, }); }); - it('repairs an existing routine-session when Hive session lacks routine context', async () => { + it.each([ + { + label: 'routine config is missing', + routineConfig: undefined, + }, + { + label: 'routine config has a mismatched routineId', + routineConfig: { + routineId: 'another-routine', + isCreationChannel: true, + }, + }, + { + label: 'legacy routine config contains nested team9Context', + routineConfig: { + routineId: ROUTINE_ID, + isCreationChannel: true, + team9Context: REQUIRED_ROUTINE_TEAM9_CONTEXT, + }, + }, + ])('repairs an existing routine-session when $label', async (testCase) => { db.limit.mockResolvedValueOnce([ { id: ROUTINE_ID, @@ -4478,7 +4502,11 @@ describe('RoutinesService — TaskCast integration', () => { ]); clawHiveService.getSession.mockResolvedValueOnce({ sessionId: `team9/${TENANT_ID}/${AGENT_ID}/dm/${CHANNEL_ID}`, - componentConfigs: {}, + componentConfigs: { + ...(testCase.routineConfig === undefined + ? {} + : { 'team9-routine-creation': testCase.routineConfig }), + }, }); usersService.getLocalePreferences.mockResolvedValueOnce({ language: 'zh-CN', @@ -4497,22 +4525,15 @@ describe('RoutinesService — TaskCast integration', () => { userId: USER_ID, sessionId: `team9/${TENANT_ID}/${AGENT_ID}/dm/${CHANNEL_ID}`, team9Context: expect.objectContaining({ - routineId: ROUTINE_ID, - creatorUserId: USER_ID, - creationChannelId: CHANNEL_ID, - isCreationChannel: true, + ...REQUIRED_ROUTINE_TEAM9_CONTEXT, language: 'zh-CN', timeZone: 'Asia/Shanghai', }), componentConfigs: expect.objectContaining({ - 'team9-routine-creation': expect.objectContaining({ + 'team9-routine-creation': { routineId: ROUTINE_ID, isCreationChannel: true, - team9Context: expect.objectContaining({ - routineId: ROUTINE_ID, - creationChannelId: CHANNEL_ID, - }), - }), + }, 'just-bash-team9-workspace': expect.objectContaining({ folderMap: expect.objectContaining({ 'routine.document': expect.objectContaining({ @@ -4767,12 +4788,7 @@ describe('RoutinesService — TaskCast integration', () => { expect.objectContaining({ sessionId: SESSION_ID, userId: USER_ID, - team9Context: expect.objectContaining({ - routineId: ROUTINE_ID, - creatorUserId: USER_ID, - creationChannelId: CHANNEL_ID, - isCreationChannel: true, - }), + team9Context: expect.objectContaining(REQUIRED_ROUTINE_TEAM9_CONTEXT), }), TENANT_ID, ); @@ -5211,6 +5227,7 @@ describe('RoutinesService — TaskCast integration', () => { team9Context: Record; }; expect(sessionArgs.team9Context).toMatchObject({ + ...REQUIRED_ROUTINE_TEAM9_CONTEXT, language: 'zh-CN', timeZone: 'Asia/Shanghai', }); @@ -5232,6 +5249,7 @@ describe('RoutinesService — TaskCast integration', () => { }; expect(sessionArgs.team9Context).not.toHaveProperty('language'); expect(sessionArgs.team9Context).toMatchObject({ + ...REQUIRED_ROUTINE_TEAM9_CONTEXT, timeZone: 'Asia/Shanghai', }); }); @@ -5249,7 +5267,10 @@ describe('RoutinesService — TaskCast integration', () => { .calls[0][1] as { team9Context: Record; }; - expect(sessionArgs.team9Context).toMatchObject({ language: 'zh-CN' }); + expect(sessionArgs.team9Context).toMatchObject({ + ...REQUIRED_ROUTINE_TEAM9_CONTEXT, + language: 'zh-CN', + }); expect(sessionArgs.team9Context).not.toHaveProperty('timeZone'); }); @@ -5268,13 +5289,9 @@ describe('RoutinesService — TaskCast integration', () => { }; expect(sessionArgs.team9Context).not.toHaveProperty('language'); expect(sessionArgs.team9Context).not.toHaveProperty('timeZone'); - // Existing fields remain intact. - expect(sessionArgs.team9Context).toMatchObject({ - routineId: expect.any(String), - creatorUserId: expect.any(String), - creationChannelId: expect.any(String), - isCreationChannel: true, - }); + expect(sessionArgs.team9Context).toMatchObject( + REQUIRED_ROUTINE_TEAM9_CONTEXT, + ); }); it('rolls back claim + channel when getLocalePreferences rejects', async () => { diff --git a/apps/server/apps/gateway/src/routines/routines.service.ts b/apps/server/apps/gateway/src/routines/routines.service.ts index dd798175..f39b948c 100644 --- a/apps/server/apps/gateway/src/routines/routines.service.ts +++ b/apps/server/apps/gateway/src/routines/routines.service.ts @@ -1359,6 +1359,10 @@ export class RoutinesService { locale: { language?: string | null; timeZone?: string | null }; }): Record { return { + source: 'team9', + scopeType: 'dm', + scopeId: params.channelId, + peerUserId: params.userId, routineId: params.routineId, creatorUserId: params.userId, creationChannelId: params.channelId, @@ -1372,13 +1376,11 @@ export class RoutinesService { routineId: string; tenantId: string; routineFolderId: string; - team9Context: Record; }): Record> { return { 'team9-routine-creation': { routineId: params.routineId, isCreationChannel: true, - team9Context: params.team9Context, }, 'just-bash-team9-workspace': { folderMap: { @@ -1409,23 +1411,19 @@ export class RoutinesService { ): boolean { if (!session) return true; - const sessionContext = session.team9Context as - | Record - | undefined; - const hasSessionContext = sessionContext?.routineId === routineId; - const componentConfigs = session.componentConfigs as | Record> | undefined; const routineConfig = componentConfigs?.['team9-routine-creation']; - const routineConfigContext = routineConfig?.team9Context as - | Record - | undefined; - const hasRoutineComponentContext = + const hasLegacyContext = + routineConfig !== undefined && + Object.keys(routineConfig).includes('team9Context'); + const hasRoutineComponentConfig = routineConfig?.routineId === routineId && - routineConfigContext?.routineId === routineId; + routineConfig?.isCreationChannel === true && + !hasLegacyContext; - return !hasSessionContext || !hasRoutineComponentContext; + return !hasRoutineComponentConfig; } private async repairRoutineCreationHiveSessionIfNeeded(params: { @@ -1477,7 +1475,6 @@ export class RoutinesService { routineId: params.routineId, tenantId: params.tenantId, routineFolderId, - team9Context, }), }, params.tenantId, @@ -1731,7 +1728,6 @@ export class RoutinesService { routineId, tenantId, routineFolderId, - team9Context, }); await this.clawHiveService.createSession( diff --git a/apps/server/libs/claw-hive/src/claw-hive.service.spec.ts b/apps/server/libs/claw-hive/src/claw-hive.service.spec.ts index 3b4dcd79..cd22b38b 100644 --- a/apps/server/libs/claw-hive/src/claw-hive.service.spec.ts +++ b/apps/server/libs/claw-hive/src/claw-hive.service.spec.ts @@ -231,6 +231,30 @@ describe('ClawHiveService', () => { body: JSON.stringify({ userId: 'user-1' }), }), ); + + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(opts.body as string) as Record; + expect(body).toEqual({ userId: 'user-1' }); + expect(Object.keys(body)).toEqual(['userId']); + }); + + it('omits application contexts when runtime team9Context is null', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 's1' })); + const params = { + userId: 'u1', + team9Context: null, + } as unknown as Parameters< + InstanceType['createSession'] + >[1]; + + await service.createSession('agent-1', params); + + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(opts.body as string) as Record; + expect(body).toEqual({ userId: 'u1' }); + expect(Object.keys(body)).toEqual(['userId']); + expect(body).not.toHaveProperty('applicationContexts'); + expect(body).not.toHaveProperty('team9Context'); }); it('URL-encodes agent IDs with special characters', async () => { @@ -266,7 +290,7 @@ describe('ClawHiveService', () => { expect(headers['X-Hive-Tenant']).toBeUndefined(); }); - it('forwards team9Context in the request body', async () => { + it('serializes team9Context under the versioned application context', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 's1' })); const team9Context = { @@ -285,7 +309,17 @@ describe('ClawHiveService', () => { const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]; const body = JSON.parse(opts.body as string) as Record; - expect(body.team9Context).toEqual(team9Context); + expect(body).toEqual({ + userId: 'u1', + applicationContexts: { + team9: { + schemaVersion: 1, + payload: team9Context, + }, + }, + }); + expect(Object.keys(body)).toEqual(['userId', 'applicationContexts']); + expect(body).not.toHaveProperty('team9Context'); }); it('returns the sessionId from the response', async () => { diff --git a/apps/server/libs/claw-hive/src/claw-hive.service.ts b/apps/server/libs/claw-hive/src/claw-hive.service.ts index 7aa7c922..817df6d1 100644 --- a/apps/server/libs/claw-hive/src/claw-hive.service.ts +++ b/apps/server/libs/claw-hive/src/claw-hive.service.ts @@ -216,12 +216,26 @@ export class ClawHiveService { }, tenantId?: string, ): Promise<{ sessionId: string }> { + const { team9Context, ...sessionParams } = params; + const body = + team9Context == null + ? sessionParams + : { + ...sessionParams, + applicationContexts: { + team9: { + schemaVersion: 1, + payload: team9Context, + }, + }, + }; + const res = await fetch( `${this.baseUrl}/api/agents/${encodeURIComponent(agentId)}/sessions`, { method: 'POST', headers: this.headers(tenantId), - body: JSON.stringify(params), + body: JSON.stringify(body), }, ); if (!res.ok) {