Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -612,24 +612,21 @@ 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,
isCreationChannel: true,
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,
Expand Down
99 changes: 58 additions & 41 deletions apps/server/apps/gateway/src/routines/routines.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,10 @@ describe('RoutinesService — TaskCast integration', () => {
.mockResolvedValue({ sessionId: 'pre-created-session' }),
getSession: jest.fn<any>().mockResolvedValue({
sessionId: 'existing-session',
team9Context: { routineId: 'routine-1' },
componentConfigs: {
'team9-routine-creation': {
routineId: 'routine-1',
team9Context: { routineId: 'routine-1' },
isCreationChannel: true,
},
},
}),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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([
{
Expand All @@ -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',
},
]);
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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({
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -5211,6 +5227,7 @@ describe('RoutinesService — TaskCast integration', () => {
team9Context: Record<string, unknown>;
};
expect(sessionArgs.team9Context).toMatchObject({
...REQUIRED_ROUTINE_TEAM9_CONTEXT,
language: 'zh-CN',
timeZone: 'Asia/Shanghai',
});
Expand All @@ -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',
});
});
Expand All @@ -5249,7 +5267,10 @@ describe('RoutinesService — TaskCast integration', () => {
.calls[0][1] as {
team9Context: Record<string, unknown>;
};
expect(sessionArgs.team9Context).toMatchObject({ language: 'zh-CN' });
expect(sessionArgs.team9Context).toMatchObject({
...REQUIRED_ROUTINE_TEAM9_CONTEXT,
language: 'zh-CN',
});
expect(sessionArgs.team9Context).not.toHaveProperty('timeZone');
});

Expand All @@ -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 () => {
Expand Down
26 changes: 11 additions & 15 deletions apps/server/apps/gateway/src/routines/routines.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,10 @@ export class RoutinesService {
locale: { language?: string | null; timeZone?: string | null };
}): Record<string, unknown> {
return {
source: 'team9',
scopeType: 'dm',
scopeId: params.channelId,
peerUserId: params.userId,
routineId: params.routineId,
creatorUserId: params.userId,
creationChannelId: params.channelId,
Expand All @@ -1372,13 +1376,11 @@ export class RoutinesService {
routineId: string;
tenantId: string;
routineFolderId: string;
team9Context: Record<string, unknown>;
}): Record<string, Record<string, unknown>> {
return {
'team9-routine-creation': {
routineId: params.routineId,
isCreationChannel: true,
team9Context: params.team9Context,
},
'just-bash-team9-workspace': {
folderMap: {
Expand Down Expand Up @@ -1409,23 +1411,19 @@ export class RoutinesService {
): boolean {
if (!session) return true;

const sessionContext = session.team9Context as
| Record<string, unknown>
| undefined;
const hasSessionContext = sessionContext?.routineId === routineId;

const componentConfigs = session.componentConfigs as
| Record<string, Record<string, unknown>>
| undefined;
const routineConfig = componentConfigs?.['team9-routine-creation'];
const routineConfigContext = routineConfig?.team9Context as
| Record<string, unknown>
| 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: {
Expand Down Expand Up @@ -1477,7 +1475,6 @@ export class RoutinesService {
routineId: params.routineId,
tenantId: params.tenantId,
routineFolderId,
team9Context,
}),
},
params.tenantId,
Expand Down Expand Up @@ -1731,7 +1728,6 @@ export class RoutinesService {
routineId,
tenantId,
routineFolderId,
team9Context,
});

await this.clawHiveService.createSession(
Expand Down
38 changes: 36 additions & 2 deletions apps/server/libs/claw-hive/src/claw-hive.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<typeof ClawHiveService>['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<string, unknown>;
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 () => {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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<string, unknown>;
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 () => {
Expand Down
16 changes: 15 additions & 1 deletion apps/server/libs/claw-hive/src/claw-hive.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading